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    whitelisted_with_env(argv, &crate::ProcessEnvSource)
43}
44
45/// [`EnvSource`](crate::EnvSource)-injecting form of [`whitelisted`].
46///
47/// The [`ENV_WHITELIST`] subset is copied from `env` rather than the process
48/// environment, so tests can prove the forward-whitelisted / drop-everything-
49/// else contract against a closed fixture map without mutating global env.
50pub fn whitelisted_with_env<S: AsRef<OsStr>, E: crate::EnvSource + ?Sized>(
51    argv: &[S],
52    env: &E,
53) -> Result<Command> {
54    anyhow::ensure!(!argv.is_empty(), "user command argv cannot be empty");
55    let program = argv[0].as_ref();
56    let mut cmd = Command::new(program);
57    if argv.len() > 1 {
58        cmd.args(&argv[1..]);
59    }
60    cmd.env_clear();
61    for key in ENV_WHITELIST {
62        if let Some(val) = env.var(key) {
63            cmd.env(key, val);
64        }
65    }
66    Ok(cmd)
67}
68
69#[cfg(test)]
70mod tests {
71    use super::*;
72    use std::collections::HashMap;
73    use std::ffi::OsString;
74
75    /// Collect the `Command`'s configured env overrides into a map. A
76    /// `None` value means the key is explicitly removed; `Some(v)` means
77    /// it is set to `v`. After `env_clear`, an unset whitelist key never
78    /// appears at all (no override entry is added in the loop above).
79    fn env_map(cmd: &Command) -> HashMap<OsString, Option<OsString>> {
80        cmd.get_envs()
81            .map(|(k, v)| (k.to_owned(), v.map(|v| v.to_owned())))
82            .collect()
83    }
84
85    #[test]
86    fn empty_argv_is_rejected() {
87        let argv: &[&str] = &[];
88        let err = whitelisted(argv).unwrap_err();
89        assert!(
90            err.to_string().contains("argv cannot be empty"),
91            "unexpected error: {err}"
92        );
93    }
94
95    #[test]
96    fn single_element_argv_sets_program_with_no_args() {
97        let cmd = whitelisted(&["echo"]).expect("single-element argv is valid");
98        assert_eq!(cmd.get_program(), OsStr::new("echo"));
99        assert_eq!(cmd.get_args().count(), 0);
100    }
101
102    #[test]
103    fn multi_element_argv_splits_program_and_args() {
104        let cmd =
105            whitelisted(&["git", "tag", "-a", "v1.0.0"]).expect("multi-element argv is valid");
106        assert_eq!(cmd.get_program(), OsStr::new("git"));
107        let args: Vec<_> = cmd.get_args().collect();
108        assert_eq!(
109            args,
110            vec![OsStr::new("tag"), OsStr::new("-a"), OsStr::new("v1.0.0")]
111        );
112    }
113
114    #[test]
115    fn whitelisted_env_is_inherited_non_whitelisted_is_dropped() {
116        // A closed fixture env proves the forwarding contract without touching
117        // process env: PATH is whitelisted and must forward verbatim; the
118        // credential-shaped key is not whitelisted and must be dropped.
119        let env = crate::MapEnvSource::new()
120            .with("PATH", "/fixture/bin")
121            .with("ANODIZER_SECRET_TOKEN", "leak-me");
122        let cmd = whitelisted_with_env(&["true"], &env).expect("valid argv");
123        let envs = env_map(&cmd);
124
125        // A whitelisted key present in the parent env is forwarded verbatim.
126        assert_eq!(
127            envs.get(OsStr::new("PATH")),
128            Some(&Some(OsString::from("/fixture/bin"))),
129            "PATH should be inherited from the whitelist verbatim"
130        );
131        // A non-whitelisted key (credential-shaped) must not leak through.
132        assert!(
133            !envs.contains_key(OsStr::new("ANODIZER_SECRET_TOKEN")),
134            "non-whitelisted env must be dropped, got: {envs:?}"
135        );
136    }
137
138    #[test]
139    fn unset_whitelist_key_adds_no_override_entry() {
140        // A fixture env with no USERPROFILE entry: the whitelist key is unset.
141        let env = crate::MapEnvSource::new();
142        let cmd = whitelisted_with_env(&["true"], &env).expect("valid argv");
143        let envs = env_map(&cmd);
144        // An unset whitelist key is skipped entirely (the loop only adds an
145        // override when the source returns a value), so it must not appear
146        // even as a removal entry.
147        assert!(
148            !envs.contains_key(OsStr::new("USERPROFILE")),
149            "unset whitelist key should add no override entry"
150        );
151    }
152}