use super::bash_tokens::tokenize;
use super::split_shell_segments;
const POD_CLIS: &[&str] = &["kubectl", "oc"];
const CONTAINER_CLIS: &[&str] = &[
"docker",
"podman",
"nerdctl",
"docker-compose",
"podman-compose",
];
const REMOTE_VERBS: &[&str] = &["exec", "rsh"];
const POD_VALUE_FLAGS: &[&str] = &[
"-c",
"--container",
"-n",
"--namespace",
"--shell",
"--timeout",
"-f",
"--filename",
"--context",
"--kubeconfig",
"--server",
"--token",
"--user",
"--cluster",
];
const POD_BOOLEAN_FLAGS: &[&str] = &[
"-i", "-t", "-it", "-ti", "-T", "-q", "--stdin", "--tty", "--no-tty", "--quiet",
];
const CONTAINER_VALUE_FLAGS: &[&str] = &[
"-e",
"--env",
"--env-file",
"-u",
"--user",
"-w",
"--workdir",
"--detach-keys",
"--index",
];
const CONTAINER_BOOLEAN_FLAGS: &[&str] = &[
"-d",
"--detach",
"-i",
"--interactive",
"-t",
"--tty",
"-it",
"-ti",
"-T",
"--no-tty",
"--privileged",
];
const SHELLS: &[&str] = &["sh", "bash", "zsh", "dash", "ash", "ksh"];
const ENV_VALUE_FLAGS: &[&str] = &["-u", "--unset", "-C", "--chdir"];
const MAX_DEPTH: usize = 4;
type FlagTables = (&'static [&'static str], &'static [&'static str]);
pub(crate) fn evaluate_pod_env_dump_command(command: &str) -> Option<String> {
if !REMOTE_VERBS.iter().any(|verb| command.contains(verb)) {
return None;
}
split_shell_segments(command)
.iter()
.any(|segment| segment_dumps_pod_env(segment))
.then(deny_reason)
}
fn segment_dumps_pod_env(segment: &str) -> bool {
let is_cli = |word: &str| POD_CLIS.contains(&word) || CONTAINER_CLIS.contains(&word);
let Ok(argv) = tokenize(segment) else {
let words: Vec<&str> = segment.split(|c: char| !is_word_byte(c)).collect();
return words.iter().any(|w| is_cli(w))
&& words.iter().any(|w| REMOTE_VERBS.contains(w))
&& words
.iter()
.any(|w| matches!(*w, "env" | "printenv" | "environ"));
};
let Some(cli) = argv.iter().position(|t| is_cli(basename(t))) else {
return false;
};
let rest = &argv[cli + 1..];
if CONTAINER_CLIS.contains(&basename(&argv[cli])) {
let tables = (CONTAINER_VALUE_FLAGS, CONTAINER_BOOLEAN_FLAGS);
return verb_positions(rest, &["exec"])
.any(|at| any_command_dumps(&rest[at + 1..], tables));
}
let separator = rest.iter().position(|t| t == "--");
let options = &rest[..separator.unwrap_or(rest.len())];
let mut verbs = verb_positions(options, REMOTE_VERBS).peekable();
if verbs.peek().is_none() {
return false;
}
if let Some(at) = separator {
return dumps_environment(&rest[at + 1..], 0);
}
verbs.any(|at| {
let args = &options[at + 1..];
any_command_dumps(args, (POD_VALUE_FLAGS, POD_BOOLEAN_FLAGS))
|| (options[at] == "exec"
&& args
.last()
.is_some_and(|last| matches!(basename(last), "env" | "printenv")))
})
}
fn verb_positions<'a>(args: &'a [String], verbs: &'a [&str]) -> impl Iterator<Item = usize> + 'a {
args.iter()
.enumerate()
.filter(|(_, t)| verbs.contains(&t.as_str()))
.map(|(at, _)| at)
}
fn any_command_dumps(args: &[String], tables: FlagTables) -> bool {
target_commands(args, tables)
.into_iter()
.any(|command| dumps_environment(command, 0))
}
fn target_commands(args: &[String], (value, boolean): FlagTables) -> Vec<&[String]> {
let mut reachable = vec![false; args.len() + 1];
reachable[0] = true;
let mut commands = Vec::new();
for (i, arg) in args.iter().enumerate() {
if !reachable[i] {
continue;
}
let skip_value = (i + 2).min(args.len());
if arg == "--" {
if let Some(command) = args.get(i + 2..) {
commands.push(command);
}
} else if !arg.starts_with('-') || arg == "-" {
let command = &args[i + 1..];
commands.push(match command.first() {
Some(first) if first == "--" => &command[1..],
_ => command,
});
} else if value.contains(&arg.as_str()) {
reachable[skip_value] = true;
} else if boolean.contains(&arg.as_str()) || arg.contains('=') {
reachable[i + 1] = true;
} else {
reachable[i + 1] = true;
reachable[skip_value] = true;
}
}
commands
}
fn dumps_environment(argv: &[String], depth: usize) -> bool {
if depth > MAX_DEPTH {
return true;
}
if argv
.iter()
.any(|t| t.starts_with("/proc/") && t.ends_with("/environ"))
{
return true;
}
let Some(at) = argv.iter().position(|t| !is_assignment(t)) else {
return false;
};
let args = &argv[at + 1..];
match basename(&argv[at]) {
"env" => env_dumps(args, depth),
"printenv" => args.iter().all(|a| a.starts_with('-')),
"set" => args.is_empty(),
"export" | "declare" | "typeset" => args.iter().all(|a| a.starts_with('-')),
"busybox" => dumps_environment(args, depth + 1),
program if SHELLS.contains(&program) => {
shell_script(args).is_some_and(|script| script_dumps_environment(script, depth + 1))
}
_ => false,
}
}
fn env_dumps(args: &[String], depth: usize) -> bool {
let mut i = 0;
while let Some(arg) = args.get(i) {
if ENV_VALUE_FLAGS.contains(&arg.as_str()) {
i += 2;
} else if arg == "-S" || arg == "--split-string" {
return args
.get(i + 1)
.is_none_or(|s| script_dumps_environment(s, depth + 1));
} else if arg == "--" {
return dumps_environment_or_empty(&args[i + 1..], depth);
} else if arg.starts_with('-') || is_assignment(arg) {
i += 1;
} else {
return dumps_environment(&args[i..], depth + 1);
}
}
true
}
fn dumps_environment_or_empty(argv: &[String], depth: usize) -> bool {
argv.is_empty() || dumps_environment(argv, depth + 1)
}
fn shell_script(args: &[String]) -> Option<&str> {
let flag = args
.iter()
.position(|a| a.starts_with('-') && !a.starts_with("--") && a.contains('c'))?;
args.get(flag + 1).map(String::as_str)
}
fn script_dumps_environment(script: &str, depth: usize) -> bool {
split_shell_segments(script)
.iter()
.any(|segment| match tokenize(segment) {
Ok(argv) => dumps_environment(&argv, depth),
Err(_) => segment
.split(|c: char| !is_word_byte(c))
.any(|w| matches!(w, "env" | "printenv" | "environ")),
})
}
fn is_assignment(token: &str) -> bool {
token.split_once('=').is_some_and(|(name, _)| {
!name.is_empty() && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
})
}
fn basename(token: &str) -> &str {
token.rsplit('/').next().unwrap_or(token)
}
fn is_word_byte(c: char) -> bool {
c.is_ascii_alphanumeric() || matches!(c, '_' | '-')
}
fn deny_reason() -> String {
"an unscoped environment dump inside a pod or container (`kubectl exec … -- env`, \
`docker exec … env`, `printenv`, `export`, `set`, `/proc/*/environ`) is refused \
(issue #7648) — a pod's or container's environment is where its Secrets are injected, \
so the dump prints them into the transcript. Query one named variable instead \
(`kubectl exec <pod> -- printenv NAME`, `docker exec <name> printenv NAME`), or test for \
its presence without printing it (`kubectl exec <pod> -- sh -c 'test -n \"$NAME\" && echo set'`)."
.to_string()
}
#[cfg(test)]
#[path = "pod_env_dump_tests.rs"]
mod tests;