1use 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
14pub 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 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 let mut secrets = store.get_all_secrets()?;
49
50 if let Some(only_keys) = only {
52 secrets.retain(|k, _| only_keys.iter().any(|o| o == k));
53 }
54
55 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 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 cmd.env("ENVVAULT_INJECTED", "true");
85
86 #[cfg(unix)]
88 {
89 use std::os::unix::process::CommandExt;
90 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 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 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 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 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 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
167pub 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#[cfg(unix)]
192fn apply_process_isolation() {
193 #[cfg(target_os = "linux")]
194 {
195 unsafe {
197 libc::prctl(libc::PR_SET_DUMPABLE, 0, 0, 0, 0);
198 }
199 }
200
201 #[cfg(target_os = "macos")]
202 {
203 unsafe {
205 libc::ptrace(libc::PT_DENY_ATTACH, 0, std::ptr::null_mut(), 0);
206 }
207 }
208}
209
210pub 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
221pub 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 #[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}