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,
}
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>,
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("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(),
default_decision: Decision::Allow,
whitelist_mode: false,
}
}
pub fn new_whitelist() -> Self {
Self {
rules_by_program: HashMap::new(),
network_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(),
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> {
self.check_with_cwd(command, None)
}
pub fn check_with_cwd(
&self,
command: &[String],
working_directory: Option<&str>,
) -> Option<RuleMatch> {
if command.is_empty() {
return None;
}
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(rules) = self.rules_by_program.get(program) {
for rule in rules {
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
}
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 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 args[i] != *s {
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);
}
}