use proc_jail::{
ArgRules, CwdPolicy, EnvPolicy, ExecError, InjectDoubleDash, ProcPolicy, ProcRequest,
RiskyBinPolicy, Violation,
};
use std::collections::HashMap;
use std::time::Duration;
fn permissive_grep_policy() -> ProcPolicy {
ProcPolicy::builder()
.allow_bin("/usr/bin/grep")
.arg_rules(
"/usr/bin/grep",
ArgRules::new()
.allowed_flags(&["-n", "-i", "-l", "-c", "-r", "-E", "-v", "--color=never"])
.max_flags(5)
.max_positionals(100)
.inject_double_dash(InjectDoubleDash::AfterFlags),
)
.env_policy(EnvPolicy::LocaleOnly)
.cwd_policy(CwdPolicy::fixed("/tmp"))
.timeout(Duration::from_secs(5))
.build()
.expect("valid policy")
}
fn echo_policy() -> ProcPolicy {
ProcPolicy::builder()
.allow_bin("/bin/echo")
.arg_rules(
"/bin/echo",
ArgRules::new()
.allowed_flags(&["-n", "-e"])
.max_flags(2)
.max_positionals(50),
)
.timeout(Duration::from_secs(5))
.build()
.expect("valid policy")
}
#[test]
fn test_null_byte_in_binary_path() {
let policy = permissive_grep_policy();
let malicious_path = "/usr/bin/grep\0/bin/bash";
let request = ProcRequest::new(malicious_path, vec!["pattern".to_string()]);
let result = policy.prepare(request);
assert!(result.is_err(), "Null byte in path should be rejected");
}
#[test]
fn test_null_byte_in_argument() {
let policy = permissive_grep_policy();
let tmp_file = "/tmp/proc_jail_null_test.txt";
std::fs::write(tmp_file, "test content\n").unwrap();
let malicious_arg = "pattern\0--file=/etc/passwd";
let request = ProcRequest::new(
"/usr/bin/grep",
vec![malicious_arg.to_string(), tmp_file.to_string()],
);
let prepared = policy.prepare(request);
assert!(
prepared.is_ok(),
"Null in arg should be accepted as literal"
);
std::fs::remove_file(tmp_file).ok();
}
#[test]
fn test_unicode_homoglyph_flag() {
let policy = permissive_grep_policy();
let homoglyph_flags = [
"\u{2010}n", "\u{2011}n", "\u{2212}n", "\u{FE63}n", "\u{FF0D}n", ];
for fake_flag in homoglyph_flags {
let request = ProcRequest::new("/usr/bin/grep", vec![fake_flag.to_string()]);
let result = policy.prepare(request);
if let Ok(prepared) = result {
let argv = prepared.argv();
if argv.contains(&"--".to_string()) {
let dash_pos = argv.iter().position(|x| x == "--").unwrap();
let flag_pos = argv.iter().position(|x| x == fake_flag).unwrap();
assert!(
dash_pos < flag_pos,
"Homoglyph should be after -- (as positional)"
);
}
}
}
}
#[test]
fn test_unicode_direction_override() {
let policy = permissive_grep_policy();
let rtl_attack = "\u{202E}n-"; let request = ProcRequest::new("/usr/bin/grep", vec![rtl_attack.to_string()]);
let result = policy.prepare(request);
if let Ok(prepared) = result {
let argv = prepared.argv();
assert!(
!argv.iter().any(|a| a == "-n"),
"RTL override should not create -n flag"
);
}
}
#[test]
fn test_unicode_in_env_var_name() {
let mut env = HashMap::new();
env.insert("LĐ_PRELOAD".to_string(), "/evil/lib.so".to_string());
env.insert("LD\u{200B}_PRELOAD".to_string(), "/evil/lib.so".to_string());
let allowed: std::collections::HashSet<String> = ["LĐ_PRELOAD", "LD\u{200B}_PRELOAD"]
.iter()
.map(|s| s.to_string())
.collect();
let policy = EnvPolicy::AllowList(allowed);
let result = policy.apply(&env);
assert!(
!result.contains_key("LD_PRELOAD"),
"Real LD_PRELOAD should not be present"
);
}
#[test]
fn test_shell_command_substitution() {
let policy = echo_policy();
let attacks = [
"$(cat /etc/passwd)",
"`cat /etc/passwd`",
"$((1+1))",
"${PATH}",
];
for attack in attacks {
let request = ProcRequest::new("/bin/echo", vec![attack.to_string()]);
let prepared = policy.prepare(request).unwrap();
let output = std::thread::spawn(move || {
tokio::runtime::Runtime::new()
.unwrap()
.block_on(prepared.spawn())
})
.join()
.unwrap()
.unwrap();
let stdout = output.stdout_string();
assert!(
stdout.contains(attack) || stdout.trim() == attack,
"Attack string '{}' should be echoed literally, got: {}",
attack,
stdout
);
}
}
#[test]
fn test_shell_pipe_and_redirect() {
let policy = echo_policy();
let attacks = [
"foo | cat /etc/passwd",
"foo > /tmp/pwned",
"foo >> /tmp/pwned",
"foo < /etc/passwd",
"foo && cat /etc/passwd",
"foo || cat /etc/passwd",
"foo; cat /etc/passwd",
"foo\ncat /etc/passwd",
];
for attack in attacks {
let request = ProcRequest::new("/bin/echo", vec![attack.to_string()]);
let prepared = policy.prepare(request).unwrap();
let output = std::thread::spawn(move || {
tokio::runtime::Runtime::new()
.unwrap()
.block_on(prepared.spawn())
})
.join()
.unwrap()
.unwrap();
assert!(
output.success(),
"Echo with '{}' should succeed",
attack.escape_debug()
);
}
}
#[test]
fn test_shell_glob_patterns() {
let policy = permissive_grep_policy();
let tmp_file = "/tmp/proc_jail_glob_test.txt";
std::fs::write(tmp_file, "test\n").unwrap();
let attacks = ["*", "/*", "/etc/*", "?", "[a-z]", "{a,b}"];
for attack in attacks {
let request = ProcRequest::new(
"/usr/bin/grep",
vec![attack.to_string(), tmp_file.to_string()],
);
let result = policy.prepare(request);
assert!(
result.is_ok(),
"Glob pattern '{}' should be accepted as literal",
attack
);
}
std::fs::remove_file(tmp_file).ok();
}
#[test]
fn test_path_traversal_in_binary() {
let policy = permissive_grep_policy();
let attacks = [
"/usr/bin/../bin/bash",
"/usr/bin/grep/../../../bin/bash",
"/usr/bin/./grep/../bash",
];
for attack in attacks {
let request = ProcRequest::new(attack, vec!["--version".to_string()]);
let result = policy.prepare(request);
match result {
Err(Violation::BinNotAllowed { canonical, .. }) => {
assert!(
canonical.contains("bash") || !canonical.contains("grep"),
"Path traversal should resolve to actual target"
);
}
Err(Violation::BinRiskyDenied { .. }) => {
}
Err(Violation::BinNotFound { .. }) => {
}
Err(Violation::ArgFlagNotAllowed { .. }) => {
}
Err(Violation::BinCanonicalizeFailed { .. }) => {
}
Ok(_) => panic!("Path traversal '{}' should not succeed", attack),
Err(e) => panic!("Unexpected error for '{}': {:?}", attack, e),
}
}
}
#[test]
fn test_symlink_chain_attack() {
use std::os::unix::fs::symlink;
let link1 = "/tmp/proc_jail_link1";
let link2 = "/tmp/proc_jail_link2";
let link3 = "/tmp/proc_jail_link3";
let _ = std::fs::remove_file(link1);
let _ = std::fs::remove_file(link2);
let _ = std::fs::remove_file(link3);
symlink("/bin/bash", link1).ok();
symlink(link1, link2).ok();
symlink(link2, link3).ok();
let policy = permissive_grep_policy();
let request = ProcRequest::new(link3, vec![]);
let result = policy.prepare(request);
assert!(
matches!(
result,
Err(Violation::BinNotAllowed { .. }) | Err(Violation::BinRiskyDenied { .. })
),
"Symlink chain should resolve to blocked binary"
);
let _ = std::fs::remove_file(link1);
let _ = std::fs::remove_file(link2);
let _ = std::fs::remove_file(link3);
}
#[test]
fn test_flag_injection_via_positional() {
let policy = permissive_grep_policy();
let tmp_file = "/tmp/proc_jail_flag_inject.txt";
std::fs::write(tmp_file, "test\n").unwrap();
let malicious_flag = "-f/etc/passwd";
let request = ProcRequest::new(
"/usr/bin/grep",
vec![
"-n".to_string(),
malicious_flag.to_string(),
tmp_file.to_string(),
],
);
let result = policy.prepare(request);
assert!(
matches!(result, Err(Violation::ArgFlagNotAllowed { .. })),
"Flag-like argument should be rejected: {:?}",
result
);
let user_pattern = "search_term"; let request = ProcRequest::new(
"/usr/bin/grep",
vec![
"-n".to_string(),
user_pattern.to_string(),
tmp_file.to_string(),
],
);
let prepared = policy.prepare(request).unwrap();
let argv = prepared.argv();
let dash_pos = argv
.iter()
.position(|x| x == "--")
.expect("-- should be injected");
let pattern_pos = argv
.iter()
.position(|x| x == user_pattern)
.expect("pattern should be in argv");
assert!(
pattern_pos > dash_pos,
"Pattern should be after --, got argv: {:?}",
argv
);
std::fs::remove_file(tmp_file).ok();
}
#[test]
fn test_flag_with_equals_bypass() {
let policy = ProcPolicy::builder()
.allow_bin("/usr/bin/grep")
.arg_rules(
"/usr/bin/grep",
ArgRules::new()
.allowed_flags(&["--color"]) .max_flags(1)
.max_positionals(2),
)
.build()
.unwrap();
let request = ProcRequest::new(
"/usr/bin/grep",
vec!["--color=always".to_string(), "pattern".to_string()],
);
let result = policy.prepare(request);
assert!(
matches!(result, Err(Violation::ArgFlagNotAllowed { ref flag }) if flag == "--color=always"),
"--color=always should not match --color allowlist entry"
);
}
#[test]
fn test_combined_short_flags_not_expanded() {
let policy = ProcPolicy::builder()
.allow_bin("/usr/bin/grep")
.arg_rules(
"/usr/bin/grep",
ArgRules::new()
.allowed_flags(&["-a", "-b", "-c"])
.max_flags(3)
.max_positionals(2),
)
.build()
.unwrap();
let request = ProcRequest::new(
"/usr/bin/grep",
vec!["-abc".to_string(), "pattern".to_string()],
);
let result = policy.prepare(request);
assert!(
matches!(result, Err(Violation::ArgFlagNotAllowed { ref flag }) if flag == "-abc"),
"-abc should not be expanded, got: {:?}",
result
);
}
#[test]
fn test_env_case_sensitivity_bypass() {
let lowercase_attacks = [
("ld_preload", "/evil/lib.so"),
("Ld_Preload", "/evil/lib.so"),
("LD_preload", "/evil/lib.so"),
("pythonpath", "/evil/python"),
("PythonPath", "/evil/python"),
];
for (key, value) in lowercase_attacks {
let mut env = HashMap::new();
env.insert(key.to_string(), value.to_string());
let policy = EnvPolicy::Fixed(env.clone());
let result = policy.apply(&HashMap::new());
assert!(
!result.contains_key("LD_PRELOAD"),
"Uppercase LD_PRELOAD must be stripped"
);
}
}
#[test]
fn test_env_with_dangerous_value() {
let mut env = HashMap::new();
env.insert("SAFE_VAR".to_string(), "$(rm -rf /)".to_string());
env.insert("ANOTHER".to_string(), "; cat /etc/passwd".to_string());
let allowed: std::collections::HashSet<String> = ["SAFE_VAR", "ANOTHER"]
.iter()
.map(|s| s.to_string())
.collect();
let policy = EnvPolicy::AllowList(allowed);
let result = policy.apply(&env);
assert_eq!(result.get("SAFE_VAR"), Some(&"$(rm -rf /)".to_string()));
}
#[tokio::test]
async fn test_stdout_limit_boundary() {
let policy = ProcPolicy::builder()
.allow_bin("/bin/dd")
.arg_rules(
"/bin/dd",
ArgRules::new().max_flags(0).max_positionals(4), )
.max_stdout(1000)
.timeout(Duration::from_secs(5))
.build()
.unwrap();
let request = ProcRequest::new(
"/bin/dd",
vec![
"if=/dev/zero".to_string(),
"bs=1".to_string(),
"count=1001".to_string(),
],
);
let prepared = policy.prepare(request).unwrap();
let result = prepared.spawn().await;
assert!(
matches!(result, Err(ExecError::StdoutLimitExceeded { limit: 1000 })),
"Should hit stdout limit at 1000 bytes, got: {:?}",
result
);
}
#[tokio::test]
async fn test_timeout_boundary() {
let policy = ProcPolicy::builder()
.allow_bin("/bin/sleep")
.arg_rules("/bin/sleep", ArgRules::new().max_positionals(1))
.timeout(Duration::from_millis(50))
.build()
.unwrap();
let request = ProcRequest::new("/bin/sleep", vec!["1".to_string()]);
let prepared = policy.prepare(request).unwrap();
let result = prepared.spawn().await;
assert!(
matches!(result, Err(ExecError::Timeout { .. })),
"Should timeout"
);
}
#[test]
fn test_cwd_symlink_escape() {
use std::os::unix::fs::symlink;
use tempfile::TempDir;
let jail = TempDir::new().unwrap();
let escape_link = jail.path().join("escape");
symlink("/etc", &escape_link).ok();
let policy = ProcPolicy::builder()
.allow_bin("/usr/bin/env")
.arg_rules("/usr/bin/env", ArgRules::new())
.risky_bin_policy(RiskyBinPolicy::Disabled)
.cwd_policy(CwdPolicy::jailed(jail.path(), jail.path()))
.build()
.unwrap();
let request = ProcRequest::new("/usr/bin/env", vec![]).with_cwd(&escape_link);
let result = policy.prepare(request);
assert!(
matches!(result, Err(Violation::CwdForbidden { .. })),
"Symlink escape from CWD jail should be blocked"
);
}
#[test]
fn test_cwd_traversal_escape() {
use tempfile::TempDir;
let jail = TempDir::new().unwrap();
let subdir = jail.path().join("subdir");
std::fs::create_dir(&subdir).unwrap();
let policy = ProcPolicy::builder()
.allow_bin("/usr/bin/env")
.arg_rules("/usr/bin/env", ArgRules::new())
.risky_bin_policy(RiskyBinPolicy::Disabled)
.cwd_policy(CwdPolicy::jailed(&subdir, &subdir))
.build()
.unwrap();
let escape_path = subdir.join("..");
let request = ProcRequest::new("/usr/bin/env", vec![]).with_cwd(&escape_path);
let result = policy.prepare(request);
assert!(
matches!(result, Err(Violation::CwdForbidden { .. })),
"Path traversal escape from CWD jail should be blocked"
);
}
#[test]
fn test_relative_path_with_slash() {
let policy = permissive_grep_policy();
let attacks = ["./usr/bin/grep", "../usr/bin/grep", "usr/bin/grep"];
for attack in attacks {
let request = ProcRequest::new(attack, vec![]);
let result = policy.prepare(request);
assert!(
matches!(result, Err(Violation::BinNotAbsolute { .. })),
"Relative path '{}' should be rejected",
attack
);
}
}
#[test]
fn test_double_slash_in_path() {
let policy = permissive_grep_policy();
let request = ProcRequest::new("//usr//bin//grep", vec!["pattern".to_string()]);
let result = policy.prepare(request);
if let Ok(prepared) = result {
assert!(
prepared.bin().to_str().unwrap().contains("grep"),
"Double slashes should canonicalize properly"
);
}
}
#[test]
fn test_subcommand_injection_via_flag_value() {
let policy = ProcPolicy::builder()
.allow_bin("/usr/bin/git")
.arg_rules(
"/usr/bin/git",
ArgRules::new()
.subcommand("status")
.allowed_flags(&["--porcelain"])
.max_flags(1)
.max_positionals(0),
)
.build()
.unwrap();
let attacks = [
vec!["push".to_string()], vec!["--porcelain".to_string()], vec!["status".to_string(), "push".to_string()], ];
for argv in attacks {
let request = ProcRequest::new("/usr/bin/git", argv.clone());
let result = policy.prepare(request);
assert!(result.is_err(), "Attack {:?} should be blocked", argv);
}
}
#[test]
fn test_max_args_boundary() {
let policy = ProcPolicy::builder()
.allow_bin("/bin/echo")
.arg_rules("/bin/echo", ArgRules::new().max_flags(0).max_positionals(3))
.build()
.unwrap();
let request = ProcRequest::new(
"/bin/echo",
vec!["a".to_string(), "b".to_string(), "c".to_string()],
);
assert!(policy.prepare(request).is_ok());
let request = ProcRequest::new(
"/bin/echo",
vec![
"a".to_string(),
"b".to_string(),
"c".to_string(),
"d".to_string(),
],
);
assert!(matches!(
policy.prepare(request),
Err(Violation::ArgTooManyPositionals { max: 3, got: 4 })
));
}
#[test]
fn test_very_long_argument() {
let policy = echo_policy();
let long_arg = "A".repeat(1_000_000); let request = ProcRequest::new("/bin/echo", vec![long_arg]);
let result = policy.prepare(request);
assert!(result.is_ok(), "Long argument should be accepted by policy");
}