#[path = "process_env_dump_remote.rs"]
mod remote;
use super::bash_tokens::tokenize;
use super::split_shell_segments;
const MARKERS: &[&str] = &["launchctl", "pm2", "ps", "environ", "ssh"];
const LAUNCHCTL_DUMP_VERBS: &[&str] = &["print", "dumpstate", "export"];
const PM2_DUMP_VERBS: &[&str] = &[
"jlist",
"prettylist",
"env",
"describe",
"desc",
"info",
"show",
"get",
"conf",
"config",
];
const PS_DASH_VALUE_OPTIONS: &str = "CGgMNOopqstUu";
const PS_BSD_VALUE_OPTIONS: &str = "kNOopTtU";
const PS_LONG_VALUE_OPTIONS: &[&str] = &[
"--cols",
"--columns",
"--format",
"--group",
"--Group",
"--lines",
"--pid",
"--ppid",
"--quick-pid",
"--rows",
"--sid",
"--sort",
"--tty",
"--user",
"--User",
"--width",
];
pub(crate) fn evaluate_process_env_dump_command(command: &str) -> Option<String> {
if !MARKERS.iter().any(|marker| command.contains(marker)) {
return None;
}
split_shell_segments(command)
.iter()
.any(|segment| segment_dumps_process_env(segment))
.then(deny_reason)
}
fn program_name(word: &str) -> &str {
let program = word.rsplit('/').next().unwrap_or(word);
program.split_once('@').map_or(program, |(name, _)| name)
}
fn dump_verbs(word: &str) -> Option<&'static [&'static str]> {
match program_name(word) {
"launchctl" => Some(LAUNCHCTL_DUMP_VERBS),
"pm2" => Some(PM2_DUMP_VERBS),
_ => None,
}
}
fn segment_dumps_process_env(segment: &str) -> bool {
let Ok(argv) = tokenize(segment) else {
let words: Vec<&str> = segment
.split(|c: char| !(c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '@')))
.collect();
return words
.iter()
.any(|w| dump_verbs(w).is_some_and(|verbs| words.iter().any(|v| verbs.contains(v))))
|| argv_dumps_env(&approximate_argv(segment));
};
argv_dumps_env(&argv)
}
fn approximate_argv(segment: &str) -> Vec<String> {
segment
.split_whitespace()
.map(|w| w.trim_matches(['\'', '"', '(', ')', '$', '`', '<', '>', ';']))
.map(str::to_string)
.collect()
}
fn argv_dumps_env(argv: &[String]) -> bool {
argv.iter().enumerate().any(|(at, word)| {
let rest = &argv[at + 1..];
dump_verbs(word)
.is_some_and(|verbs| subcommands(rest).into_iter().any(|sub| verbs.contains(&sub)))
|| (program_name(word) == "ps" && ps_shows_environment(rest))
|| names_a_process_environ(word)
|| (program_name(word) == "ssh" && remote::ssh_dumps_remote_env(rest))
})
}
fn ps_shows_environment(args: &[String]) -> bool {
let mut i = 0;
while let Some(arg) = args.get(i) {
i += 1;
if arg.starts_with("--") {
i += usize::from(PS_LONG_VALUE_OPTIONS.contains(&arg.as_str()));
continue;
}
let (cluster, env, values) = match arg.strip_prefix('-') {
Some(cluster) => (cluster, 'E', PS_DASH_VALUE_OPTIONS),
None if arg.bytes().all(|b| b.is_ascii_alphabetic()) => {
(arg.as_str(), 'e', PS_BSD_VALUE_OPTIONS)
}
None => continue,
};
for (at, c) in cluster.char_indices() {
if c == env {
return true;
}
if values.contains(c) {
i += usize::from(at + 1 == cluster.len());
break;
}
}
}
false
}
fn names_a_process_environ(word: &str) -> bool {
let Some(at) = word.find("/proc/") else {
return false;
};
if word.chars().any(char::is_whitespace) {
return false; }
let parts: Vec<&str> = word[at..].split('/').filter(|p| !p.is_empty()).collect();
let last = parts.last().copied().unwrap_or_default();
parts.len() >= 3 && (last == "environ" || last.contains(['*', '?', '[']))
}
fn subcommands(args: &[String]) -> Vec<&str> {
let mut reachable = vec![false; args.len() + 2];
reachable[0] = true;
let mut found = Vec::new();
for (i, arg) in args.iter().enumerate() {
if !reachable[i] {
continue;
}
if arg == "--" {
reachable[i + 1] = true;
} else if !arg.starts_with('-') || arg == "-" {
found.push(arg.as_str());
} else {
reachable[i + 1] = true;
reachable[i + 2] = !arg.contains('=') || reachable[i + 2];
}
}
found
}
fn deny_reason() -> String {
"a query that prints another process's environment (`launchctl print`, `launchctl \
dumpstate`, `pm2 jlist|prettylist|env|describe|show|info`, `pm2 get|conf`, `ps eww` / \
`ps -E`, a `/proc/<pid>/environ` read, or `ssh <host> env|printenv`) is refused (issue \
#8756) — a launchd job's EnvironmentVariables, a pm2 process's env or module config, and \
a server's environment carry its API keys, so the dump prints them into the transcript, \
and a `| jq` or `| grep` filter after it cannot be proven to drop every value. For \
status, use `launchctl list <label>` (PID and LastExitStatus), `pm2 ls` / `pm2 pid \
<name>`, `ps aux` / `ps -o pid,command`, or `ssh <host> <status command>`."
.to_string()
}
#[cfg(test)]
#[path = "process_env_dump_tests.rs"]
mod tests;