pub(crate) fn is_dangerous_env_var(key: &str) -> bool {
key.starts_with("LD_")
|| key.starts_with("DYLD_")
|| key == "BASH_ENV"
|| key == "ENV"
|| key == "CDPATH"
|| key == "GLOBIGNORE"
|| key.starts_with("BASH_FUNC_")
|| key == "PROMPT_COMMAND"
|| key == "IFS"
|| key == "PYTHONSTARTUP"
|| key == "PYTHONPATH"
|| key == "NODE_OPTIONS"
|| key == "NODE_PATH"
|| key == "PERL5OPT"
|| key == "PERL5LIB"
|| key == "RUBYOPT"
|| key == "RUBYLIB"
|| key == "GEM_PATH"
|| key == "GEM_HOME"
|| key == "JAVA_TOOL_OPTIONS"
|| key == "_JAVA_OPTIONS"
|| key == "DOTNET_STARTUP_HOOKS"
|| key == "GOFLAGS"
|| key == "OP_SERVICE_ACCOUNT_TOKEN"
|| key == "OP_CONNECT_TOKEN"
|| key == "OP_CONNECT_HOST"
|| key.starts_with("OP_SESSION_")
}
fn matches_env_var_patterns(key: &str, patterns: &[String]) -> bool {
for pattern in patterns {
if let Some(prefix) = pattern.strip_suffix('*') {
if prefix.contains('*') {
continue;
}
if key.starts_with(prefix) {
return true;
}
} else if !pattern.contains('*') && key == *pattern {
return true;
}
}
false
}
pub(crate) fn is_env_var_allowed(key: &str, allowed_env_vars: &[String]) -> bool {
matches_env_var_patterns(key, allowed_env_vars)
}
pub(crate) fn is_env_var_denied(key: &str, denied_env_vars: &[String]) -> bool {
matches_env_var_patterns(key, denied_env_vars)
}
pub(crate) fn validate_env_var_patterns(patterns: &[String], field_name: &str) -> Option<String> {
for pattern in patterns {
if pattern.contains('*') && !pattern.ends_with('*') {
return Some(format!(
"Invalid {} pattern '{}': '*' is only valid as a trailing suffix",
field_name, pattern
));
}
if pattern.starts_with('*') && pattern.len() > 1 {
return Some(format!(
"Invalid {} pattern '{}': use a bare '*' to match all variables, or a specific prefix like 'AWS_*'",
field_name, pattern
));
}
}
None
}
fn is_valid_env_var_name(name: &str) -> bool {
let mut chars = name.chars();
match chars.next() {
Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
_ => return false,
}
chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
}
pub(crate) fn validate_set_vars(
set_vars: &std::collections::HashMap<String, String>,
) -> Option<String> {
for key in set_vars.keys() {
if key == "PATH" {
return Some(
"Invalid set_vars key 'PATH': PATH is reserved; use allow_vars/deny_vars to \
control it"
.to_string(),
);
}
if key.starts_with("NONO_") {
return Some(format!(
"Invalid set_vars key '{}': the NONO_* prefix is reserved",
key
));
}
if !is_valid_env_var_name(key) {
return Some(format!(
"Invalid set_vars key '{}': environment variable names must match \
[A-Za-z_][A-Za-z0-9_]*",
key
));
}
}
None
}
pub(super) fn should_skip_env_var(
key: &str,
config_env_vars: &[(&str, &str)],
blocked_extra: &[&str],
) -> bool {
config_env_vars.iter().any(|(ek, _)| *ek == key)
|| blocked_extra.contains(&key)
|| is_dangerous_env_var(key)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_blocks_op_service_account_token() {
assert!(is_dangerous_env_var("OP_SERVICE_ACCOUNT_TOKEN"));
}
#[test]
fn test_blocks_op_connect_token() {
assert!(is_dangerous_env_var("OP_CONNECT_TOKEN"));
}
#[test]
fn test_blocks_op_connect_host() {
assert!(is_dangerous_env_var("OP_CONNECT_HOST"));
}
#[test]
fn test_blocks_op_session_prefix() {
assert!(is_dangerous_env_var("OP_SESSION_my_team"));
assert!(is_dangerous_env_var("OP_SESSION_personal"));
assert!(is_dangerous_env_var("OP_SESSION_"));
}
#[test]
fn test_allows_unrelated_env_vars() {
assert!(!is_dangerous_env_var("OPENAI_API_KEY"));
assert!(!is_dangerous_env_var("OPERATOR_TOKEN"));
assert!(!is_dangerous_env_var("OPTIONS"));
assert!(!is_dangerous_env_var("HOME"));
assert!(!is_dangerous_env_var("PATH"));
}
#[test]
fn test_blocks_linker_injection() {
assert!(is_dangerous_env_var("LD_PRELOAD"));
assert!(is_dangerous_env_var("DYLD_INSERT_LIBRARIES"));
}
#[test]
fn test_blocks_interpreter_injection() {
assert!(is_dangerous_env_var("NODE_OPTIONS"));
assert!(is_dangerous_env_var("PYTHONPATH"));
assert!(is_dangerous_env_var("RUBYOPT"));
}
#[test]
fn test_env_var_allowed_exact_match() {
let allowed: Vec<String> = vec!["PATH".into(), "HOME".into()];
assert!(is_env_var_allowed("PATH", &allowed));
assert!(is_env_var_allowed("HOME", &allowed));
}
#[test]
fn test_env_var_allowed_exact_no_match() {
let allowed: Vec<String> = vec!["PATH".into(), "HOME".into()];
assert!(!is_env_var_allowed("SECRET", &allowed));
}
#[test]
fn test_env_var_allowed_prefix_match() {
let allowed: Vec<String> = vec!["AWS_*".into()];
assert!(is_env_var_allowed("AWS_REGION", &allowed));
assert!(is_env_var_allowed("AWS_SECRET_ACCESS_KEY", &allowed));
}
#[test]
fn test_env_var_allowed_prefix_no_match() {
let allowed: Vec<String> = vec!["AWS_*".into()];
assert!(!is_env_var_allowed("GCP_REGION", &allowed));
}
#[test]
fn test_env_var_allowed_empty_list() {
let allowed: Vec<String> = vec![];
assert!(!is_env_var_allowed("PATH", &allowed));
}
#[test]
fn test_env_var_allowed_bare_star() {
let allowed: Vec<String> = vec!["*".into()];
assert!(is_env_var_allowed("ANYTHING", &allowed));
assert!(is_env_var_allowed("PATH", &allowed));
}
#[test]
fn test_env_var_allowed_prefix_does_not_match_partial() {
let allowed: Vec<String> = vec!["AWS_*".into()];
assert!(!is_env_var_allowed("AWS", &allowed));
}
#[test]
fn test_env_var_allowed_prefix_matches_empty_suffix() {
let allowed: Vec<String> = vec!["AWS_*".into()];
assert!(is_env_var_allowed("AWS_", &allowed));
}
#[test]
fn test_env_var_allowed_mixed_patterns() {
let allowed: Vec<String> = vec!["PATH".into(), "AWS_*".into()];
assert!(is_env_var_allowed("PATH", &allowed));
assert!(is_env_var_allowed("AWS_REGION", &allowed));
assert!(!is_env_var_allowed("HOME", &allowed));
}
#[test]
fn test_env_var_allowed_mid_star_ignored() {
let allowed: Vec<String> = vec!["A*B".into()];
assert!(!is_env_var_allowed("AXB", &allowed));
assert!(!is_env_var_allowed("A*B", &allowed));
}
#[test]
fn test_validate_valid_patterns() {
let patterns: Vec<String> = vec!["PATH".into(), "AWS_*".into(), "*".into()];
assert!(validate_env_var_patterns(&patterns, "allow_vars").is_none());
}
#[test]
fn test_validate_rejects_mid_star() {
let patterns: Vec<String> = vec!["A*B".into()];
let err = validate_env_var_patterns(&patterns, "allow_vars");
assert!(err.is_some());
assert!(err.as_ref().is_some_and(|e| e.contains("A*B")));
}
#[test]
fn test_validate_rejects_leading_star_with_suffix() {
let patterns: Vec<String> = vec!["*X".into()];
let err = validate_env_var_patterns(&patterns, "allow_vars");
assert!(err.is_some());
assert!(err.as_ref().is_some_and(|e| e.contains("*X")));
}
#[test]
fn test_validate_accepts_bare_star() {
let patterns: Vec<String> = vec!["*".into()];
assert!(validate_env_var_patterns(&patterns, "allow_vars").is_none());
}
#[test]
fn test_validate_exact_name_no_star() {
let patterns: Vec<String> = vec!["PATH".into()];
assert!(validate_env_var_patterns(&patterns, "allow_vars").is_none());
}
#[test]
fn test_validate_deny_vars_field_name_in_error() {
let patterns: Vec<String> = vec!["A*B".into()];
let err = validate_env_var_patterns(&patterns, "deny_vars");
assert!(err.as_ref().is_some_and(|e| e.contains("deny_vars")));
assert!(err.as_ref().is_some_and(|e| e.contains("A*B")));
}
#[test]
fn test_env_var_denied_exact_match() {
let denied: Vec<String> = vec!["GH_TOKEN".into(), "ANTHROPIC_API_KEY".into()];
assert!(is_env_var_denied("GH_TOKEN", &denied));
assert!(is_env_var_denied("ANTHROPIC_API_KEY", &denied));
}
#[test]
fn test_env_var_denied_prefix_match() {
let denied: Vec<String> = vec!["GITHUB_*".into()];
assert!(is_env_var_denied("GITHUB_TOKEN", &denied));
assert!(is_env_var_denied("GITHUB_ACTIONS", &denied));
assert!(!is_env_var_denied("GH_TOKEN", &denied));
}
#[test]
fn test_env_var_denied_no_match() {
let denied: Vec<String> = vec!["GH_TOKEN".into()];
assert!(!is_env_var_denied("PATH", &denied));
assert!(!is_env_var_denied("HOME", &denied));
}
#[test]
fn test_env_var_denied_empty_list() {
let denied: Vec<String> = vec![];
assert!(!is_env_var_denied("GH_TOKEN", &denied));
}
#[test]
fn test_env_var_denied_overrides_allowed() {
let denied: Vec<String> = vec!["GH_TOKEN".into()];
let allowed: Vec<String> = vec!["GH_TOKEN".into()];
assert!(is_env_var_denied("GH_TOKEN", &denied));
assert!(is_env_var_allowed("GH_TOKEN", &allowed));
}
fn set_vars_from(pairs: &[(&str, &str)]) -> std::collections::HashMap<String, String> {
pairs
.iter()
.map(|(k, v)| ((*k).to_string(), (*v).to_string()))
.collect()
}
#[test]
fn test_set_vars_accepts_normal_keys() {
let set_vars = set_vars_from(&[("RUST_LOG", "debug"), ("MY_VAR", "value")]);
assert_eq!(validate_set_vars(&set_vars), None);
}
#[test]
fn test_set_vars_accepts_dangerous_keys() {
let set_vars = set_vars_from(&[("LD_PRELOAD", "/tmp/x.so")]);
assert_eq!(validate_set_vars(&set_vars), None);
let set_vars = set_vars_from(&[("NODE_OPTIONS", "--max-old-space-size=4096")]);
assert_eq!(validate_set_vars(&set_vars), None);
let set_vars = set_vars_from(&[("DYLD_INSERT_LIBRARIES", "/tmp/x.dylib")]);
assert_eq!(validate_set_vars(&set_vars), None);
}
#[test]
fn test_set_vars_rejects_path() {
let set_vars = set_vars_from(&[("PATH", "/usr/bin")]);
assert!(validate_set_vars(&set_vars).is_some());
}
#[test]
fn test_set_vars_rejects_nono_prefix() {
let set_vars = set_vars_from(&[("NONO_FOO", "bar")]);
assert!(validate_set_vars(&set_vars).is_some());
let set_vars = set_vars_from(&[("NONO_CAP_FILE", "/tmp/cap")]);
assert!(validate_set_vars(&set_vars).is_some());
}
#[test]
fn test_set_vars_rejects_invalid_names() {
assert!(validate_set_vars(&set_vars_from(&[("", "v")])).is_some());
assert!(validate_set_vars(&set_vars_from(&[("1FOO", "v")])).is_some());
assert!(validate_set_vars(&set_vars_from(&[("A=B", "v")])).is_some());
assert!(validate_set_vars(&set_vars_from(&[("MY-VAR", "v")])).is_some());
}
#[test]
fn test_set_vars_empty_is_ok() {
let set_vars: std::collections::HashMap<String, String> = std::collections::HashMap::new();
assert_eq!(validate_set_vars(&set_vars), None);
}
}