Skip to main content

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}