anodizer_core/user_command.rs
1//! Spawn a user-supplied command (e.g. `publisher.cmd`) with a clean,
2//! whitelisted environment.
3//!
4//! Centralised here so the `Command::new(<arbitrary>)` shell-out lives
5//! inside the module-boundaries allow-list. Inlining this in the CLI
6//! crate would put `Command::new` outside the allow-list and counts
7//! as a boundary violation.
8
9use std::ffi::OsStr;
10use std::process::Command;
11
12use anyhow::Result;
13
14/// Environment variables that are inherited from the parent process
15/// when constructing a sandboxed `Command`. Anything else must be
16/// explicitly added via `Command::env`.
17///
18/// This whitelist exists to prevent accidental leakage of release
19/// credentials (`GITHUB_TOKEN`, `COSIGN_*`, signing keys, etc.) into
20/// arbitrary user-supplied commands.
21pub const ENV_WHITELIST: &[&str] = &[
22 "HOME",
23 "USER",
24 "USERPROFILE",
25 "TMPDIR",
26 "TMP",
27 "TEMP",
28 "PATH",
29 "SYSTEMROOT",
30];
31
32/// Construct a `Command` whose argv is `argv` and whose environment is
33/// reset to the [`ENV_WHITELIST`] subset of the parent's env. The first
34/// element of `argv` is the program; the rest are arguments. The caller
35/// is responsible for adding any further env vars / cwd / I/O config
36/// before invoking `output()`.
37///
38/// Returns `Err` when `argv` is empty — surfacing a clear error at the
39/// allow-listed boundary is preferable to deferring failure to the
40/// kernel via an empty `program` path.
41pub fn whitelisted<S: AsRef<OsStr>>(argv: &[S]) -> Result<Command> {
42 anyhow::ensure!(!argv.is_empty(), "user command argv cannot be empty");
43 let program = argv[0].as_ref();
44 let mut cmd = Command::new(program);
45 if argv.len() > 1 {
46 cmd.args(&argv[1..]);
47 }
48 cmd.env_clear();
49 for key in ENV_WHITELIST {
50 if let Ok(val) = std::env::var(key) {
51 cmd.env(key, val);
52 }
53 }
54 Ok(cmd)
55}
56
57#[cfg(test)]
58mod tests {
59 use super::*;
60 use std::collections::HashMap;
61 use std::ffi::OsString;
62
63 /// Collect the `Command`'s configured env overrides into a map. A
64 /// `None` value means the key is explicitly removed; `Some(v)` means
65 /// it is set to `v`. After `env_clear`, an unset whitelist key never
66 /// appears at all (no override entry is added in the loop above).
67 fn env_map(cmd: &Command) -> HashMap<OsString, Option<OsString>> {
68 cmd.get_envs()
69 .map(|(k, v)| (k.to_owned(), v.map(|v| v.to_owned())))
70 .collect()
71 }
72
73 #[test]
74 fn empty_argv_is_rejected() {
75 let argv: &[&str] = &[];
76 let err = whitelisted(argv).unwrap_err();
77 assert!(
78 err.to_string().contains("argv cannot be empty"),
79 "unexpected error: {err}"
80 );
81 }
82
83 #[test]
84 fn single_element_argv_sets_program_with_no_args() {
85 let cmd = whitelisted(&["echo"]).expect("single-element argv is valid");
86 assert_eq!(cmd.get_program(), OsStr::new("echo"));
87 assert_eq!(cmd.get_args().count(), 0);
88 }
89
90 #[test]
91 fn multi_element_argv_splits_program_and_args() {
92 let cmd =
93 whitelisted(&["git", "tag", "-a", "v1.0.0"]).expect("multi-element argv is valid");
94 assert_eq!(cmd.get_program(), OsStr::new("git"));
95 let args: Vec<_> = cmd.get_args().collect();
96 assert_eq!(
97 args,
98 vec![OsStr::new("tag"), OsStr::new("-a"), OsStr::new("v1.0.0")]
99 );
100 }
101
102 #[test]
103 #[serial_test::serial]
104 fn whitelisted_env_is_inherited_non_whitelisted_is_dropped() {
105 // Forward the real parent PATH verbatim rather than clobbering the
106 // process PATH to a sentinel: a global `set_var("PATH", …)` leaks into
107 // every concurrently-running test (Rust runs the binary's tests on
108 // parallel threads), breaking PATH-dependent probes like
109 // `util::find_binary`. Reading the real value proves the same
110 // forwarding contract without mutating shared state.
111 let real_path = std::env::var_os("PATH");
112
113 // SAFETY: `#[serial]` excludes other env-mutating tests; the
114 // credential key is namespaced and removed below before the guard ends.
115 unsafe {
116 std::env::set_var("ANODIZER_SECRET_TOKEN", "leak-me");
117 }
118 let cmd = whitelisted(&["true"]).expect("valid argv");
119 let envs = env_map(&cmd);
120 unsafe {
121 std::env::remove_var("ANODIZER_SECRET_TOKEN");
122 }
123
124 // A whitelisted key present in the parent env is forwarded verbatim.
125 assert_eq!(
126 envs.get(OsStr::new("PATH")),
127 Some(&real_path),
128 "PATH should be inherited from the whitelist verbatim"
129 );
130 // A non-whitelisted key (credential-shaped) must not leak through.
131 assert!(
132 !envs.contains_key(OsStr::new("ANODIZER_SECRET_TOKEN")),
133 "non-whitelisted env must be dropped, got: {envs:?}"
134 );
135 }
136
137 #[test]
138 fn unset_whitelist_key_adds_no_override_entry() {
139 // SAFETY: see above — single-threaded env mutation in test context.
140 unsafe {
141 std::env::remove_var("USERPROFILE");
142 }
143 let cmd = whitelisted(&["true"]).expect("valid argv");
144 let envs = env_map(&cmd);
145 // An unset whitelist key is skipped entirely (the loop only adds an
146 // override when `std::env::var` returns `Ok`), so it must not appear
147 // even as a removal entry.
148 assert!(
149 !envs.contains_key(OsStr::new("USERPROFILE")),
150 "unset whitelist key should add no override entry"
151 );
152 }
153}