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
//! 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> {
whitelisted_with_env(argv, &crate::ProcessEnvSource)
}
/// [`EnvSource`](crate::EnvSource)-injecting form of [`whitelisted`].
///
/// The [`ENV_WHITELIST`] subset is copied from `env` rather than the process
/// environment, so tests can prove the forward-whitelisted / drop-everything-
/// else contract against a closed fixture map without mutating global env.
pub fn whitelisted_with_env<S: AsRef<OsStr>, E: crate::EnvSource + ?Sized>(
argv: &[S],
env: &E,
) -> 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 Some(val) = 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]
fn whitelisted_env_is_inherited_non_whitelisted_is_dropped() {
// A closed fixture env proves the forwarding contract without touching
// process env: PATH is whitelisted and must forward verbatim; the
// credential-shaped key is not whitelisted and must be dropped.
let env = crate::MapEnvSource::new()
.with("PATH", "/fixture/bin")
.with("ANODIZER_SECRET_TOKEN", "leak-me");
let cmd = whitelisted_with_env(&["true"], &env).expect("valid argv");
let envs = env_map(&cmd);
// A whitelisted key present in the parent env is forwarded verbatim.
assert_eq!(
envs.get(OsStr::new("PATH")),
Some(&Some(OsString::from("/fixture/bin"))),
"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() {
// A fixture env with no USERPROFILE entry: the whitelist key is unset.
let env = crate::MapEnvSource::new();
let cmd = whitelisted_with_env(&["true"], &env).expect("valid argv");
let envs = env_map(&cmd);
// An unset whitelist key is skipped entirely (the loop only adds an
// override when the source returns a value), 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"
);
}
}