Skip to main content

envvault/cli/commands/
run.rs

1//! `envvault run` — inject secrets into a child process.
2
3use std::io::{BufRead, BufReader};
4use std::path::Path;
5use std::process::{Command, Stdio};
6
7use zeroize::Zeroize;
8
9use crate::cli::output;
10use crate::cli::{load_keyfile, prompt_password_for_vault, vault_path, Cli};
11use crate::errors::{EnvVaultError, Result};
12use crate::vault::VaultStore;
13
14/// Execute the `run` command.
15pub fn execute(
16    cli: &Cli,
17    command: &[String],
18    clean_env: bool,
19    only: Option<&[String]>,
20    exclude: Option<&[String]>,
21    redact_output: bool,
22    allowed_commands: Option<&[String]>,
23) -> Result<()> {
24    if command.is_empty() {
25        return Err(EnvVaultError::NoCommandSpecified);
26    }
27
28    // Validate the command against the allow list (if configured).
29    if let Some(allowed) = allowed_commands {
30        validate_allowed_command(&command[0], allowed)?;
31    }
32
33    let path = vault_path(cli)?;
34
35    let keyfile = load_keyfile(cli)?;
36    let vault_id = path.to_string_lossy();
37    let password = prompt_password_for_vault(Some(&vault_id))?;
38    let store = match VaultStore::open(&path, password.as_bytes(), keyfile.as_deref()) {
39        Ok(store) => store,
40        Err(e) => {
41            #[cfg(feature = "audit-log")]
42            crate::audit::log_auth_failure(cli, &e.to_string());
43            return Err(e);
44        }
45    };
46
47    // Decrypt all secrets into memory.
48    let mut secrets = store.get_all_secrets()?;
49
50    // Apply --only filter: keep only the specified keys.
51    if let Some(only_keys) = only {
52        secrets.retain(|k, _| only_keys.iter().any(|o| o == k));
53    }
54
55    // Apply --exclude filter: remove the specified keys.
56    if let Some(exclude_keys) = exclude {
57        secrets.retain(|k, _| !exclude_keys.iter().any(|e| e == k));
58    }
59
60    if clean_env {
61        output::success(&format!(
62            "Injected {} secrets into clean environment",
63            secrets.len()
64        ));
65    } else {
66        output::success(&format!(
67            "Injected {} secrets into environment",
68            secrets.len()
69        ));
70    }
71
72    // Build the child process.
73    let program = &command[0];
74    let args = &command[1..];
75
76    let mut cmd = Command::new(program);
77    cmd.args(args);
78
79    if clean_env {
80        cmd.env_clear();
81    }
82
83    // Always inject the marker so child processes know they're running under envvault.
84    cmd.env("ENVVAULT_INJECTED", "true");
85
86    // Apply process isolation on Unix (prevent /proc/pid/environ leaks).
87    #[cfg(unix)]
88    {
89        use std::os::unix::process::CommandExt;
90        // SAFETY: apply_process_isolation calls prctl/ptrace, which are simple
91        // kernel syscalls with no memory side effects. Called after fork()
92        // but before exec().
93        unsafe {
94            cmd.pre_exec(|| {
95                apply_process_isolation();
96                Ok(())
97            });
98        }
99    }
100
101    #[cfg(feature = "audit-log")]
102    let secret_count = secrets.len();
103
104    let status = if redact_output {
105        // Pipe stdout/stderr and redact secret values.
106        cmd.stdout(Stdio::piped());
107        cmd.stderr(Stdio::piped());
108
109        let mut child = cmd.envs(&secrets).spawn()?;
110
111        let secret_values: Vec<String> = secrets
112            .values()
113            .filter(|v| !v.is_empty())
114            .cloned()
115            .collect();
116
117        // Read and redact stdout.
118        if let Some(stdout) = child.stdout.take() {
119            let values = secret_values.clone();
120            std::thread::spawn(move || {
121                let reader = BufReader::new(stdout);
122                for line in reader.lines().map_while(|r| r.ok()) {
123                    println!("{}", redact_line(&line, &values));
124                }
125            });
126        }
127
128        // Read and redact stderr.
129        if let Some(stderr) = child.stderr.take() {
130            let values = secret_values;
131            std::thread::spawn(move || {
132                let reader = BufReader::new(stderr);
133                for line in reader.lines().map_while(|r| r.ok()) {
134                    eprintln!("{}", redact_line(&line, &values));
135                }
136            });
137        }
138
139        child.wait()?
140    } else {
141        cmd.envs(&secrets).status()?
142    };
143
144    // Zeroize plaintext secrets — the child process has its own copies.
145    for v in secrets.values_mut() {
146        v.zeroize();
147    }
148
149    #[cfg(feature = "audit-log")]
150    crate::audit::log_read_audit(
151        cli,
152        "run",
153        None,
154        Some(&format!("{secret_count} secrets injected")),
155    );
156
157    // Forward the child's exit code.
158    match status.code() {
159        Some(0) => Ok(()),
160        Some(code) => Err(EnvVaultError::ChildProcessFailed(code)),
161        None => Err(EnvVaultError::CommandFailed(
162            "child process terminated by signal".into(),
163        )),
164    }
165}
166
167/// Validate that a command is in the allowed list.
168///
169/// Extracts the basename from the command path (e.g. `/usr/bin/node` → `node`)
170/// and checks if it's in the allow list.
171pub fn validate_allowed_command(program: &str, allowed: &[String]) -> Result<()> {
172    let basename = Path::new(program)
173        .file_name()
174        .and_then(|n| n.to_str())
175        .unwrap_or(program);
176
177    if allowed.iter().any(|a| a == basename) {
178        Ok(())
179    } else {
180        Err(EnvVaultError::CommandNotAllowed(format!(
181            "'{basename}' is not in the allowed commands list: {:?}",
182            allowed
183        )))
184    }
185}
186
187/// Apply OS-level process isolation to prevent secret leaks.
188///
189/// - Linux: `PR_SET_DUMPABLE(0)` prevents reading `/proc/pid/environ`
190/// - macOS: `PT_DENY_ATTACH` prevents debugger attachment
191#[cfg(unix)]
192fn apply_process_isolation() {
193    #[cfg(target_os = "linux")]
194    {
195        // SAFETY: prctl is a simple kernel syscall with no memory side effects.
196        unsafe {
197            libc::prctl(libc::PR_SET_DUMPABLE, 0, 0, 0, 0);
198        }
199    }
200
201    #[cfg(target_os = "macos")]
202    {
203        // SAFETY: ptrace is a simple kernel syscall with no memory side effects.
204        unsafe {
205            libc::ptrace(libc::PT_DENY_ATTACH, 0, std::ptr::null_mut(), 0);
206        }
207    }
208}
209
210/// Replace any occurrence of secret values in a line with `[REDACTED]`.
211pub fn redact_line(line: &str, secret_values: &[String]) -> String {
212    let mut result = line.to_string();
213    for value in secret_values {
214        if !value.is_empty() {
215            result = result.replace(value.as_str(), "[REDACTED]");
216        }
217    }
218    result
219}
220
221/// Filter secrets by only/exclude lists. Used for testing.
222pub fn filter_secrets(
223    secrets: &mut std::collections::HashMap<String, String>,
224    only: Option<&[String]>,
225    exclude: Option<&[String]>,
226) {
227    if let Some(only_keys) = only {
228        secrets.retain(|k, _| only_keys.iter().any(|o| o == k));
229    }
230    if let Some(exclude_keys) = exclude {
231        secrets.retain(|k, _| !exclude_keys.iter().any(|e| e == k));
232    }
233}
234
235#[cfg(test)]
236mod tests {
237    use super::*;
238    use std::collections::HashMap;
239
240    #[test]
241    fn filter_only_keeps_specified_keys() {
242        let mut secrets = HashMap::from([
243            ("A".into(), "1".into()),
244            ("B".into(), "2".into()),
245            ("C".into(), "3".into()),
246        ]);
247        let only = vec!["A".to_string(), "C".to_string()];
248        filter_secrets(&mut secrets, Some(&only), None);
249        assert_eq!(secrets.len(), 2);
250        assert!(secrets.contains_key("A"));
251        assert!(secrets.contains_key("C"));
252        assert!(!secrets.contains_key("B"));
253    }
254
255    #[test]
256    fn filter_exclude_removes_specified_keys() {
257        let mut secrets = HashMap::from([
258            ("A".into(), "1".into()),
259            ("B".into(), "2".into()),
260            ("C".into(), "3".into()),
261        ]);
262        let exclude = vec!["B".to_string()];
263        filter_secrets(&mut secrets, None, Some(&exclude));
264        assert_eq!(secrets.len(), 2);
265        assert!(secrets.contains_key("A"));
266        assert!(secrets.contains_key("C"));
267    }
268
269    #[test]
270    fn filter_only_and_exclude_combined() {
271        let mut secrets = HashMap::from([
272            ("A".into(), "1".into()),
273            ("B".into(), "2".into()),
274            ("C".into(), "3".into()),
275        ]);
276        let only = vec!["A".to_string(), "B".to_string()];
277        let exclude = vec!["B".to_string()];
278        filter_secrets(&mut secrets, Some(&only), Some(&exclude));
279        assert_eq!(secrets.len(), 1);
280        assert!(secrets.contains_key("A"));
281    }
282
283    #[test]
284    fn filter_no_flags_keeps_all() {
285        let mut secrets = HashMap::from([("A".into(), "1".into()), ("B".into(), "2".into())]);
286        filter_secrets(&mut secrets, None, None);
287        assert_eq!(secrets.len(), 2);
288    }
289
290    #[test]
291    fn redact_replaces_secret_values() {
292        let secrets = vec!["s3cr3t".to_string(), "p@ssw0rd".to_string()];
293        assert_eq!(
294            redact_line("my password is s3cr3t", &secrets),
295            "my password is [REDACTED]"
296        );
297        assert_eq!(redact_line("auth: p@ssw0rd", &secrets), "auth: [REDACTED]");
298    }
299
300    #[test]
301    fn redact_leaves_safe_lines_alone() {
302        let secrets = vec!["secret123".to_string()];
303        assert_eq!(redact_line("no secrets here", &secrets), "no secrets here");
304    }
305
306    #[test]
307    fn redact_handles_empty_secrets() {
308        let secrets: Vec<String> = vec![];
309        assert_eq!(redact_line("some output", &secrets), "some output");
310    }
311
312    #[test]
313    fn redact_multiple_occurrences() {
314        let secrets = vec!["tok".to_string()];
315        assert_eq!(
316            redact_line("tok and tok again", &secrets),
317            "[REDACTED] and [REDACTED] again"
318        );
319    }
320
321    // --- allowed_commands tests ---
322
323    #[test]
324    fn allowed_command_passes_for_basename() {
325        let allowed = vec!["node".to_string(), "python".to_string()];
326        assert!(validate_allowed_command("node", &allowed).is_ok());
327        assert!(validate_allowed_command("python", &allowed).is_ok());
328    }
329
330    #[test]
331    fn allowed_command_extracts_basename_from_path() {
332        let allowed = vec!["node".to_string()];
333        assert!(validate_allowed_command("/usr/bin/node", &allowed).is_ok());
334        assert!(validate_allowed_command("/usr/local/bin/node", &allowed).is_ok());
335    }
336
337    #[test]
338    fn disallowed_command_returns_error() {
339        let allowed = vec!["node".to_string()];
340        let err = validate_allowed_command("python", &allowed).unwrap_err();
341        assert!(err.to_string().contains("not in the allowed commands list"));
342    }
343
344    #[test]
345    fn disallowed_command_with_full_path_returns_error() {
346        let allowed = vec!["node".to_string()];
347        let err = validate_allowed_command("/usr/bin/python", &allowed).unwrap_err();
348        assert!(err.to_string().contains("python"));
349    }
350}