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