1#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
23pub enum Severity {
24 ReadOnly,
26 WritesState,
28 Elevated,
30 Critical,
32}
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum Category {
38 Destructive,
40 Irreversible,
43 Privilege,
45 RemoteExec,
47 Service,
49 Availability,
51 Package,
53 Redirect,
55 Secrets,
57 Unknown,
59}
60
61#[derive(Debug, Clone, PartialEq, Eq)]
65pub struct Finding {
66 pub severity: Severity,
67 pub category: Category,
68 pub subject: String,
69}
70
71impl Finding {
72 fn new(severity: Severity, category: Category, subject: impl Into<String>) -> Self {
73 Self {
74 severity,
75 category,
76 subject: subject.into(),
77 }
78 }
79}
80
81#[derive(Debug, Clone, Default, PartialEq, Eq)]
84pub struct CommandImpact {
85 pub findings: Vec<Finding>,
86}
87
88impl CommandImpact {
89 pub fn verdict(&self) -> Severity {
92 self.findings
93 .iter()
94 .map(|f| f.severity)
95 .max()
96 .unwrap_or(Severity::ReadOnly)
97 }
98
99 pub fn callouts(&self) -> Vec<&Finding> {
102 let mut v: Vec<&Finding> = self.findings.iter().collect();
103 v.sort_by_key(|f| std::cmp::Reverse(f.severity));
104 v
105 }
106
107 pub fn uses_sudo(&self) -> bool {
109 self.findings
110 .iter()
111 .any(|f| f.category == Category::Privilege)
112 }
113
114 pub fn is_elevated_or_destructive(&self) -> bool {
117 self.verdict() >= Severity::Elevated
118 }
119}
120
121const READ_ONLY_HEADS: &[&str] = &[
129 "ls",
130 "ll",
131 "pwd",
132 "cd",
133 "echo",
134 "printf",
135 "grep",
136 "egrep",
137 "fgrep",
138 "rg",
139 "ag",
140 "awk",
141 "cut",
142 "sort",
143 "uniq",
144 "wc",
145 "tr",
146 "column",
147 "true",
148 "false",
149 "test",
150 "stat",
151 "file",
152 "locate",
153 "which",
154 "type",
155 "whereis",
156 "readlink",
157 "realpath",
158 "basename",
159 "dirname",
160 "du",
161 "df",
162 "free",
163 "uptime",
164 "uname",
165 "hostname",
166 "hostnamectl",
167 "whoami",
168 "id",
169 "groups",
170 "w",
171 "who",
172 "last",
173 "date",
174 "cal",
175 "printenv",
176 "set",
177 "ps",
178 "pgrep",
179 "pstree",
180 "top",
181 "htop",
182 "vmstat",
183 "iostat",
184 "mpstat",
185 "lscpu",
186 "lsblk",
187 "lsusb",
188 "lspci",
189 "lsof",
190 "ip",
191 "ifconfig",
192 "ss",
193 "netstat",
194 "ping",
195 "ping6",
196 "traceroute",
197 "dig",
198 "nslookup",
199 "host",
200 "getent",
201 "curl",
202 "wget",
203 "nc",
204 "telnet",
205 "openssl",
206 "md5sum",
207 "sha256sum",
208 "sha1sum",
209 "cksum",
210 "sensors",
211 "dmesg",
212 "journalctl",
213 "pg_dump",
216 "pg_dumpall",
217 "mysqldump",
218 "gzip",
219 "gunzip",
220 "zcat",
221 "gzcat",
222 "xz",
223 "bzip2",
224 "base64",
225 "jq",
226 "yq",
227 "nl",
228 "tac",
229];
230
231const WRAPPERS: &[&str] = &[
234 "sudo", "doas", "su", "env", "nice", "ionice", "nohup", "timeout", "stdbuf", "setsid", "time",
235 "command", "builtin", "exec", "xargs",
236];
237
238const INTERPRETERS: &[&str] = &[
240 "sh", "bash", "dash", "zsh", "ksh", "fish", "python", "python2", "python3", "perl", "ruby",
241 "node", "php", "lua", "tclsh",
242];
243
244const FETCHERS: &[&str] = &["curl", "wget", "fetch", "http", "https"];
246
247const SECRET_PATTERNS: &[&str] = &[
251 "id_rsa",
252 "id_ed25519",
253 "id_ecdsa",
254 "id_dsa",
255 ".pem",
256 ".key",
257 ".pfx",
258 ".p12",
259 "/etc/shadow",
260 "/etc/gshadow",
261 "/.ssh/",
262 "authorized_keys",
263 ".env",
264 ".aws/credentials",
265 ".npmrc",
266 ".netrc",
267];
268
269const SYSTEM_PATHS: &[&str] = &[
271 "/", "/*", "/bin", "/sbin", "/usr", "/etc", "/var", "/lib", "/lib64", "/boot", "/opt", "/root",
272 "/home", "/srv", "/dev", "/proc", "/sys",
273];
274
275#[derive(Debug, Clone, Copy, PartialEq, Eq)]
280enum RedirKind {
281 Truncate,
283 Append,
285 Other,
287}
288
289#[derive(Debug, Clone, PartialEq, Eq)]
290struct Redirect {
291 kind: RedirKind,
292 target: String,
293}
294
295#[derive(Debug, Clone, Default, PartialEq, Eq)]
298struct Segment {
299 words: Vec<String>,
300 redirects: Vec<Redirect>,
301 substitutions: Vec<String>,
302 after_pipe: bool,
303}
304
305fn capture_balanced(chars: &[char], start: usize) -> (String, usize) {
308 let mut depth = 0i32;
309 let mut inner = String::new();
310 let mut i = start;
311 while i < chars.len() {
312 match chars[i] {
313 '(' => {
314 depth += 1;
315 if depth == 1 {
316 i += 1;
317 continue;
318 }
319 }
320 ')' => {
321 depth -= 1;
322 if depth == 0 {
323 return (inner, i + 1);
324 }
325 }
326 _ => {}
327 }
328 inner.push(chars[i]);
329 i += 1;
330 }
331 (inner, i)
332}
333
334fn capture_backtick(chars: &[char], start: usize) -> (String, usize) {
336 let mut inner = String::new();
337 let mut i = start + 1;
338 while i < chars.len() && chars[i] != '`' {
339 inner.push(chars[i]);
340 i += 1;
341 }
342 (inner, (i + 1).min(chars.len()))
343}
344
345fn redirect_kind(chars: &[char], i: usize) -> (RedirKind, usize) {
348 let next = chars.get(i + 1).copied();
349 if chars[i] == '>' {
350 match next {
351 Some('>') => (RedirKind::Append, 2),
352 Some('|') => (RedirKind::Truncate, 2),
353 Some('&') => (RedirKind::Other, 2), _ => (RedirKind::Truncate, 1),
355 }
356 } else {
357 match next {
358 Some('<') | Some('&') => (RedirKind::Other, 2),
359 _ => (RedirKind::Other, 1),
360 }
361 }
362}
363
364fn read_target(chars: &[char], mut i: usize) -> (String, usize) {
368 let n = chars.len();
369 let mut target = String::new();
370 while i < n && !chars[i].is_whitespace() && !matches!(chars[i], '|' | '&' | ';' | '>' | '<') {
371 match chars[i] {
372 '\'' | '"' => {
373 let q = chars[i];
374 i += 1;
375 while i < n && chars[i] != q {
376 target.push(chars[i]);
377 i += 1;
378 }
379 i += 1;
380 }
381 '$' if chars.get(i + 1) == Some(&'(') => {
382 let (inner, ni) = capture_balanced(chars, i + 1);
383 target.push_str("$(");
384 target.push_str(&inner);
385 target.push(')');
386 i = ni;
387 }
388 '(' | ')' => break,
389 c => {
390 target.push(c);
391 i += 1;
392 }
393 }
394 }
395 (target, i)
396}
397
398fn segment(command: &str) -> Vec<Segment> {
403 let chars: Vec<char> = command.chars().collect();
404 let n = chars.len();
405 let mut segments: Vec<Segment> = Vec::new();
406 let mut cur = Segment::default();
407 let mut word = String::new();
408 let mut next_after_pipe = false;
409 let mut i = 0usize;
410
411 macro_rules! flush_word {
412 () => {
413 if !word.is_empty() {
414 cur.words.push(std::mem::take(&mut word));
415 }
416 };
417 }
418 macro_rules! flush_segment {
419 ($after:expr) => {{
420 flush_word!();
421 cur.after_pipe = next_after_pipe;
422 if !cur.words.is_empty() || !cur.redirects.is_empty() || !cur.substitutions.is_empty() {
423 segments.push(std::mem::take(&mut cur));
424 } else {
425 cur = Segment::default();
426 }
427 next_after_pipe = $after;
428 }};
429 }
430
431 while i < n {
432 let c = chars[i];
433 match c {
434 '\\' => {
435 if i + 1 < n {
436 word.push(chars[i + 1]);
437 i += 2;
438 } else {
439 i += 1;
440 }
441 }
442 '\'' => {
443 i += 1;
444 while i < n && chars[i] != '\'' {
445 word.push(chars[i]);
446 i += 1;
447 }
448 i += 1;
449 }
450 '"' => {
451 i += 1;
452 while i < n && chars[i] != '"' {
453 if chars[i] == '\\' && i + 1 < n {
454 word.push(chars[i + 1]);
455 i += 2;
456 continue;
457 }
458 if chars[i] == '$' && i + 1 < n && chars[i + 1] == '(' {
459 let (inner, ni) = capture_balanced(&chars, i + 1);
460 cur.substitutions.push(inner);
461 i = ni;
462 continue;
463 }
464 if chars[i] == '`' {
465 let (inner, ni) = capture_backtick(&chars, i);
466 cur.substitutions.push(inner);
467 i = ni;
468 continue;
469 }
470 word.push(chars[i]);
471 i += 1;
472 }
473 i += 1;
474 }
475 '$' if i + 1 < n && chars[i + 1] == '(' => {
476 let (inner, ni) = capture_balanced(&chars, i + 1);
477 cur.substitutions.push(inner);
478 i = ni;
479 }
480 '`' => {
481 let (inner, ni) = capture_backtick(&chars, i);
482 cur.substitutions.push(inner);
483 i = ni;
484 }
485 c if c.is_whitespace() => {
486 flush_word!();
487 i += 1;
488 }
489 '|' => {
490 if chars.get(i + 1) == Some(&'|') {
491 flush_segment!(false);
492 i += 2;
493 } else {
494 flush_segment!(true);
495 i += 1;
496 }
497 }
498 '&' => {
499 if chars.get(i + 1) == Some(&'&') {
500 flush_segment!(false);
501 i += 2;
502 } else if chars.get(i + 1) == Some(&'>') {
503 flush_word!();
505 let span = if chars.get(i + 2) == Some(&'>') { 3 } else { 2 };
506 i += span;
507 while i < n && chars[i].is_whitespace() {
508 i += 1;
509 }
510 let (target, ni) = read_target(&chars, i);
511 i = ni;
512 cur.redirects.push(Redirect {
513 kind: RedirKind::Other,
514 target,
515 });
516 } else {
517 flush_segment!(false);
518 i += 1;
519 }
520 }
521 ';' => {
522 flush_segment!(false);
523 i += 1;
524 }
525 '(' | ')' => {
526 flush_segment!(false);
527 i += 1;
528 }
529 '>' | '<' => {
530 if !word.is_empty() && word.chars().all(|c| c.is_ascii_digit()) {
533 word.clear();
534 } else {
535 flush_word!();
536 }
537 let (kind, span) = redirect_kind(&chars, i);
538 i += span;
539 while i < n && chars[i].is_whitespace() {
540 i += 1;
541 }
542 let (target, ni) = read_target(&chars, i);
543 i = ni;
544 cur.redirects.push(Redirect { kind, target });
545 }
546 _ => {
547 word.push(c);
548 i += 1;
549 }
550 }
551 }
552 if !word.is_empty() {
554 cur.words.push(word);
555 }
556 cur.after_pipe = next_after_pipe;
557 if !cur.words.is_empty() || !cur.redirects.is_empty() || !cur.substitutions.is_empty() {
558 segments.push(cur);
559 }
560 segments
561}
562
563fn basename(s: &str) -> &str {
569 s.rsplit('/').next().unwrap_or(s)
570}
571
572fn is_assignment(w: &str) -> bool {
574 match w.split_once('=') {
575 Some((k, _)) => {
576 !k.is_empty()
577 && !k.contains('/')
578 && k.chars().all(|c| c.is_alphanumeric() || c == '_')
579 && k.chars()
580 .next()
581 .is_some_and(|c| c.is_alphabetic() || c == '_')
582 }
583 None => false,
584 }
585}
586
587fn skip_wrapper_args(wrapper: &str, rest: &[String]) -> usize {
590 let mut k = 0;
591 match wrapper {
592 "env" => {
593 while k < rest.len() && (is_assignment(&rest[k]) || rest[k].starts_with('-')) {
594 k += 1;
595 }
596 }
597 "timeout" => {
598 while k < rest.len() && rest[k].starts_with('-') {
599 k += 1;
600 }
601 if k < rest.len() {
602 k += 1; }
604 }
605 "nice" | "ionice" => {
606 while k < rest.len() && rest[k].starts_with('-') {
607 let takes_val = matches!(rest[k].as_str(), "-n" | "-c" | "-p");
608 k += 1;
609 if takes_val && k < rest.len() {
610 k += 1;
611 }
612 }
613 }
614 "sudo" | "doas" => {
615 while k < rest.len() && rest[k].starts_with('-') {
616 let takes_val = matches!(
617 rest[k].as_str(),
618 "-u" | "-g" | "-p" | "-C" | "-h" | "-r" | "-t" | "-U"
619 );
620 k += 1;
621 if takes_val && k < rest.len() {
622 k += 1;
623 }
624 }
625 }
626 _ => {
627 while k < rest.len() && rest[k].starts_with('-') {
628 k += 1;
629 }
630 }
631 }
632 k
633}
634
635fn resolve_head(seg: &Segment) -> Option<(String, Vec<String>, bool)> {
639 let words = &seg.words;
640 let mut idx = 0;
641 let mut privileged = false;
642 loop {
643 while idx < words.len() && is_assignment(&words[idx]) {
644 idx += 1;
645 }
646 let w = words.get(idx)?;
647 if WRAPPERS.contains(&w.as_str()) {
648 if matches!(w.as_str(), "sudo" | "su" | "doas") {
649 privileged = true;
650 }
651 idx += 1;
652 idx += skip_wrapper_args(w, words.get(idx..).unwrap_or(&[]));
653 continue;
654 }
655 if w.starts_with('-') {
656 return None;
659 }
660 let args = words.get(idx + 1..).unwrap_or(&[]).to_vec();
661 return Some((w.clone(), args, privileged));
662 }
663}
664
665fn positionals(args: &[String]) -> Vec<&String> {
667 args.iter().filter(|a| !a.starts_with('-')).collect()
668}
669
670fn first_subcommand(args: &[String]) -> Option<&str> {
672 args.iter()
673 .find(|a| !a.starts_with('-'))
674 .map(|s| s.as_str())
675}
676
677fn has_flag(args: &[String], wanted: &[char], long: &[&str]) -> bool {
680 args.iter().any(|a| {
681 (a.starts_with('-')
682 && !a.starts_with("--")
683 && a.chars().skip(1).any(|c| wanted.contains(&c)))
684 || long.contains(&a.as_str())
685 })
686}
687
688fn is_system_path(p: &str) -> bool {
689 if p == "/" || p == "/*" {
690 return true;
691 }
692 let t = p.trim_end_matches('/');
693 SYSTEM_PATHS.contains(&t) || p.contains("/*")
694}
695
696fn looks_unbounded(p: &str) -> bool {
697 p.contains('*') || p.contains('?') || p.starts_with('$') || p.contains("/$")
698}
699
700fn is_secret_path(p: &str) -> bool {
701 let lower = p.to_lowercase();
702 SECRET_PATTERNS.iter().any(|s| lower.contains(s))
703}
704
705fn is_block_device(p: &str) -> bool {
707 p.starts_with("/dev/") && !is_devnull(p)
708}
709
710fn is_control_file(p: &str) -> bool {
713 const CONTROL: &[&str] = &[
714 "/etc/sudoers",
715 "/etc/passwd",
716 "/etc/shadow",
717 "/etc/gshadow",
718 "/etc/group",
719 "/etc/crontab",
720 "/etc/fstab",
721 "/etc/hosts",
722 "/etc/resolv.conf",
723 "/etc/environment",
724 ];
725 CONTROL.contains(&p)
726 || p.contains("authorized_keys")
727 || p.starts_with("/etc/sudoers.d/")
728 || p.starts_with("/etc/cron.")
729 || p.starts_with("/etc/systemd/")
730}
731
732fn is_under_system_path(p: &str) -> bool {
735 const ROOTS: &[&str] = &[
736 "/etc/",
737 "/var/lib/",
738 "/boot/",
739 "/usr/",
740 "/lib/",
741 "/lib64/",
742 "/bin/",
743 "/sbin/",
744 "/root",
745 "/sys/",
746 "/proc/",
747 ];
748 is_system_path(p) || ROOTS.iter().any(|r| p.starts_with(r))
749}
750
751fn is_home(p: &str) -> bool {
753 p == "~" || p == "$HOME" || p.starts_with("~/") || p.starts_with("$HOME/")
754}
755
756fn classify(segments: &[Segment], depth: usize) -> Vec<Finding> {
759 let mut findings = Vec::new();
760
761 let heads: Vec<Option<(String, bool)>> = segments
764 .iter()
765 .map(|s| resolve_head(s).map(|(h, _, p)| (basename(&h).to_string(), p)))
766 .collect();
767 let first_fetcher = heads.iter().position(|h| {
771 h.as_ref()
772 .is_some_and(|(n, _)| FETCHERS.contains(&n.as_str()))
773 });
774 if let Some(fi) = first_fetcher {
775 for (idx, h) in heads.iter().enumerate() {
776 if idx <= fi {
777 continue;
778 }
779 if let Some((name, priv_)) = h {
780 if INTERPRETERS.contains(&name.as_str()) {
781 let sev = if *priv_ {
782 Severity::Critical
783 } else {
784 Severity::Elevated
785 };
786 findings.push(Finding::new(sev, Category::RemoteExec, "curl|sh"));
787 }
788 }
789 }
790 }
791
792 for seg in segments {
793 classify_segment(seg, &mut findings, depth);
794 if depth == 0 {
795 for inner in &seg.substitutions {
796 if !inner.trim().is_empty() {
797 findings.extend(classify(&segment(inner), depth + 1));
798 }
799 }
800 }
801 }
802 findings
803}
804
805fn classify_segment(seg: &Segment, findings: &mut Vec<Finding>, depth: usize) {
807 for r in &seg.redirects {
808 classify_redirect(r, findings);
809 }
810 if let Some((head, args, privileged)) = resolve_head(seg) {
811 classify_head(basename(&head), &args, privileged, findings, depth);
812 }
813}
814
815fn classify_redirect(r: &Redirect, findings: &mut Vec<Finding>) {
818 let t = &r.target;
819 if t.is_empty() || is_devnull(t) {
820 return;
821 }
822 match r.kind {
823 RedirKind::Truncate => {
824 if is_block_device(t) {
825 findings.push(Finding::new(
826 Severity::Critical,
827 Category::Irreversible,
828 t.clone(),
829 ));
830 } else if is_control_file(t) || is_under_system_path(t) {
831 findings.push(Finding::new(
832 Severity::Elevated,
833 Category::Redirect,
834 t.clone(),
835 ));
836 } else {
837 findings.push(Finding::new(
838 Severity::WritesState,
839 Category::Redirect,
840 t.clone(),
841 ));
842 }
843 }
844 RedirKind::Append => {
847 if is_control_file(t) {
848 findings.push(Finding::new(
849 Severity::Elevated,
850 Category::Redirect,
851 t.clone(),
852 ));
853 }
854 }
855 RedirKind::Other => {}
856 }
857}
858
859fn is_devnull(t: &str) -> bool {
860 matches!(t, "/dev/null" | "/dev/stdout" | "/dev/stderr")
861}
862
863#[allow(clippy::too_many_lines)]
872fn classify_head(
873 head: &str,
874 args: &[String],
875 privileged: bool,
876 findings: &mut Vec<Finding>,
877 depth: usize,
878) {
879 if privileged {
880 findings.push(Finding::new(
881 Severity::Elevated,
882 Category::Privilege,
883 "sudo",
884 ));
885 }
886
887 if head == "mkfs" || head.starts_with("mkfs.") {
889 findings.push(Finding::new(
890 Severity::Critical,
891 Category::Irreversible,
892 "mkfs",
893 ));
894 return;
895 }
896
897 match head {
898 "rm" => {
899 let recursive = has_flag(args, &['r', 'f', 'R'], &["--recursive", "--force"]);
900 let ps = positionals(args);
901 let critical = ps.iter().any(|p| {
905 is_system_path(p)
906 || is_home(p)
907 || (recursive && (looks_unbounded(p) || is_under_system_path(p)))
908 });
909 let absolute = ps.iter().any(|p| p.starts_with('/'));
911 if critical {
912 findings.push(Finding::new(
913 Severity::Critical,
914 Category::Irreversible,
915 "rm",
916 ));
917 } else if recursive && absolute {
918 findings.push(Finding::new(
919 Severity::Elevated,
920 Category::Destructive,
921 "rm -rf",
922 ));
923 } else {
924 let s = if recursive { "rm -rf" } else { "rm" };
925 findings.push(Finding::new(
926 Severity::WritesState,
927 Category::Destructive,
928 s,
929 ));
930 }
931 }
932 "rmdir" | "unlink" => {
933 findings.push(Finding::new(
934 Severity::WritesState,
935 Category::Destructive,
936 head,
937 ));
938 }
939 "shred" | "wipefs" | "fdisk" | "sgdisk" | "parted" | "mkswap" => {
940 findings.push(Finding::new(
941 Severity::Critical,
942 Category::Irreversible,
943 head,
944 ));
945 }
946 "dd" => {
947 if let Some(of) = args.iter().find_map(|a| a.strip_prefix("of=")) {
948 if of.starts_with("/dev/") {
949 findings.push(Finding::new(
950 Severity::Critical,
951 Category::Irreversible,
952 "dd",
953 ));
954 } else {
955 findings.push(Finding::new(
956 Severity::WritesState,
957 Category::Destructive,
958 "dd",
959 ));
960 }
961 }
962 }
963 "truncate" => {
964 findings.push(Finding::new(
965 Severity::WritesState,
966 Category::Destructive,
967 "truncate",
968 ));
969 }
970 "find" => {
971 if args.iter().any(|a| a == "-delete") {
972 findings.push(Finding::new(
973 Severity::WritesState,
974 Category::Destructive,
975 "find -delete",
976 ));
977 }
978 if let Some(pos) = args
983 .iter()
984 .position(|a| matches!(a.as_str(), "-exec" | "-execdir" | "-ok" | "-okdir"))
985 {
986 let rest = args.get(pos + 1..).unwrap_or(&[]);
987 let end = rest
988 .iter()
989 .position(|a| a == ";" || a == "+" || a == "\\;")
990 .unwrap_or(rest.len());
991 if let Some(cmd) = rest.get(..end).and_then(<[String]>::first) {
992 classify_head(
993 basename(cmd),
994 rest.get(1..end).unwrap_or(&[]),
995 false,
996 findings,
997 depth,
998 );
999 }
1000 }
1001 }
1002 "sed" => {
1003 if args.iter().any(|a| {
1004 a == "-i"
1005 || a.starts_with("--in-place")
1006 || (a.starts_with("-i") && a.len() > 2 && !a.starts_with("--"))
1007 }) {
1008 findings.push(Finding::new(
1009 Severity::WritesState,
1010 Category::Destructive,
1011 "sed -i",
1012 ));
1013 }
1014 }
1015 "tee" => {
1016 if positionals(args)
1019 .iter()
1020 .any(|p| is_control_file(p) || is_under_system_path(p) || is_block_device(p))
1021 {
1022 findings.push(Finding::new(Severity::Elevated, Category::Redirect, "tee"));
1023 } else {
1024 findings.push(Finding::new(
1025 Severity::WritesState,
1026 Category::Redirect,
1027 "tee",
1028 ));
1029 }
1030 }
1031 "mv" | "cp" | "install" | "ln" => {
1032 findings.push(Finding::new(
1033 Severity::WritesState,
1034 Category::Destructive,
1035 head,
1036 ));
1037 }
1038 "chmod" => {
1039 let recursive = has_flag(args, &['R'], &["--recursive"]);
1040 let world = positionals(args)
1041 .iter()
1042 .any(|p| p.contains("777") || p.contains("o+w") || p.contains("a+w"));
1043 let s = if world {
1044 "chmod 777"
1045 } else if recursive {
1046 "chmod -R"
1047 } else {
1048 "chmod"
1049 };
1050 findings.push(Finding::new(
1051 Severity::WritesState,
1052 Category::Destructive,
1053 s,
1054 ));
1055 }
1056 "chown" | "chgrp" => {
1057 let recursive = has_flag(args, &['R'], &["--recursive"]);
1058 let s = if recursive {
1059 format!("{head} -R")
1060 } else {
1061 head.to_string()
1062 };
1063 findings.push(Finding::new(
1064 Severity::WritesState,
1065 Category::Destructive,
1066 s,
1067 ));
1068 }
1069 "systemctl" => match first_subcommand(args) {
1070 Some("reboot") | Some("poweroff") | Some("halt") => {
1071 findings.push(Finding::new(
1072 Severity::Critical,
1073 Category::Availability,
1074 "systemctl",
1075 ));
1076 }
1077 Some(s @ ("stop" | "restart" | "kill" | "disable" | "mask" | "isolate")) => {
1078 findings.push(Finding::new(
1079 Severity::Elevated,
1080 Category::Service,
1081 format!("systemctl {s}"),
1082 ));
1083 }
1084 Some(s @ ("start" | "enable" | "reload" | "daemon-reload" | "set-default")) => {
1085 findings.push(Finding::new(
1086 Severity::WritesState,
1087 Category::Service,
1088 format!("systemctl {s}"),
1089 ));
1090 }
1091 _ => {}
1092 },
1093 "service" => {
1094 if positionals(args)
1095 .iter()
1096 .any(|p| matches!(p.as_str(), "stop" | "restart" | "reload"))
1097 {
1098 findings.push(Finding::new(
1099 Severity::Elevated,
1100 Category::Service,
1101 "service",
1102 ));
1103 }
1104 }
1105 "kill" | "pkill" | "killall" => {
1106 if !args.iter().any(|a| a == "-0" || a == "-l" || a == "-L") {
1109 findings.push(Finding::new(Severity::Elevated, Category::Service, head));
1110 }
1111 }
1112 "reboot" | "shutdown" | "halt" | "poweroff" | "init" => {
1113 findings.push(Finding::new(
1114 Severity::Critical,
1115 Category::Availability,
1116 head,
1117 ));
1118 }
1119 "docker" | "podman" => {
1120 let ps = positionals(args);
1124 let s1 = ps.first().map(|s| s.as_str());
1125 let s2 = ps.get(1).map(|s| s.as_str());
1126 match (s1, s2) {
1127 (Some("system"), Some("prune")) | (Some("volume"), Some("rm" | "prune")) => {
1128 findings.push(Finding::new(
1129 Severity::Elevated,
1130 Category::Destructive,
1131 format!("{head} {} prune", s1.unwrap_or("")),
1132 ));
1133 }
1134 (Some("rm" | "rmi"), _)
1135 | (Some("prune"), _)
1136 | (Some("container" | "image" | "network"), Some("rm" | "prune")) => {
1137 findings.push(Finding::new(
1138 Severity::WritesState,
1139 Category::Destructive,
1140 format!("{head} rm"),
1141 ));
1142 }
1143 (Some("compose"), Some(s @ ("down" | "stop" | "kill" | "restart")))
1144 | (Some(s @ ("stop" | "kill" | "down" | "restart")), _) => {
1145 findings.push(Finding::new(
1146 Severity::Elevated,
1147 Category::Service,
1148 format!("{head} {s}"),
1149 ));
1150 }
1151 (Some("compose"), Some(s @ ("up" | "start"))) => {
1152 findings.push(Finding::new(
1153 Severity::WritesState,
1154 Category::Service,
1155 format!("{head} compose {s}"),
1156 ));
1157 }
1158 _ => {}
1159 }
1160 }
1161 "kubectl" => match first_subcommand(args) {
1162 Some("delete") => {
1163 findings.push(Finding::new(
1164 Severity::Critical,
1165 Category::Destructive,
1166 "kubectl delete",
1167 ));
1168 }
1169 Some("drain") => {
1170 findings.push(Finding::new(
1171 Severity::Elevated,
1172 Category::Service,
1173 "kubectl drain",
1174 ));
1175 }
1176 Some(
1177 s @ ("apply" | "create" | "patch" | "replace" | "scale" | "cordon" | "uncordon"),
1178 ) => {
1179 findings.push(Finding::new(
1180 Severity::WritesState,
1181 Category::Service,
1182 format!("kubectl {s}"),
1183 ));
1184 }
1185 _ => {}
1186 },
1187 "git" => match first_subcommand(args) {
1188 Some("reset") if args.iter().any(|a| a == "--hard") => {
1189 findings.push(Finding::new(
1190 Severity::WritesState,
1191 Category::Irreversible,
1192 "git reset --hard",
1193 ));
1194 }
1195 Some("clean") if has_flag(args, &['f'], &["--force"]) => {
1196 findings.push(Finding::new(
1197 Severity::WritesState,
1198 Category::Irreversible,
1199 "git clean -f",
1200 ));
1201 }
1202 Some("push") if args.iter().any(|a| a == "--force" || a == "-f") => {
1205 findings.push(Finding::new(
1206 Severity::Elevated,
1207 Category::Destructive,
1208 "git push --force",
1209 ));
1210 }
1211 Some(s @ ("checkout" | "restore")) if has_flag(args, &['f'], &["--force"]) => {
1212 findings.push(Finding::new(
1213 Severity::WritesState,
1214 Category::Destructive,
1215 format!("git {s} --force"),
1216 ));
1217 }
1218 Some("rm") => {
1219 findings.push(Finding::new(
1220 Severity::WritesState,
1221 Category::Destructive,
1222 "git rm",
1223 ));
1224 }
1225 _ => {}
1228 },
1229 "apt" | "apt-get" | "dnf" | "yum" | "zypper" => match first_subcommand(args) {
1230 Some(s @ ("remove" | "purge" | "autoremove" | "erase")) => {
1231 findings.push(Finding::new(
1232 Severity::Elevated,
1233 Category::Package,
1234 format!("{head} {s}"),
1235 ));
1236 }
1237 Some(
1238 s @ ("install" | "upgrade" | "update" | "dist-upgrade" | "full-upgrade"
1239 | "reinstall"),
1240 ) => {
1241 findings.push(Finding::new(
1242 Severity::WritesState,
1243 Category::Package,
1244 format!("{head} {s}"),
1245 ));
1246 }
1247 _ => {}
1248 },
1249 "pacman" => {
1250 let bundle = args
1254 .iter()
1255 .find(|a| a.starts_with('-') && !a.starts_with("--"))
1256 .map(String::as_str)
1257 .unwrap_or("");
1258 let op = bundle.chars().nth(1);
1259 let mods: String = bundle.chars().skip(2).collect();
1260 let read = matches!(op, Some('Q' | 'F' | 'T'))
1261 || (op == Some('S')
1262 && mods
1263 .chars()
1264 .any(|c| matches!(c, 's' | 'i' | 'l' | 'p' | 'g')));
1265 if read {
1266 } else if op == Some('R') {
1268 findings.push(Finding::new(
1269 Severity::Elevated,
1270 Category::Package,
1271 "pacman -R",
1272 ));
1273 } else if matches!(op, Some('S' | 'U')) {
1274 findings.push(Finding::new(
1275 Severity::WritesState,
1276 Category::Package,
1277 "pacman -S",
1278 ));
1279 }
1280 }
1281 "apk" => match first_subcommand(args) {
1282 Some("del") => findings.push(Finding::new(
1283 Severity::Elevated,
1284 Category::Package,
1285 "apk del",
1286 )),
1287 Some("add") | Some("upgrade") => {
1288 findings.push(Finding::new(
1289 Severity::WritesState,
1290 Category::Package,
1291 "apk add",
1292 ));
1293 }
1294 _ => {}
1295 },
1296 "pip" | "pip3" | "npm" | "gem" | "cargo" | "snap" | "flatpak" | "brew" => {
1297 if positionals(args).iter().any(|p| {
1298 matches!(
1299 p.as_str(),
1300 "install" | "uninstall" | "remove" | "upgrade" | "update"
1301 )
1302 }) {
1303 findings.push(Finding::new(Severity::WritesState, Category::Package, head));
1304 }
1305 }
1306 "rsync" => {
1307 if args
1308 .iter()
1309 .any(|a| a == "--delete" || a.starts_with("--delete"))
1310 {
1311 findings.push(Finding::new(
1312 Severity::WritesState,
1313 Category::Destructive,
1314 "rsync --delete",
1315 ));
1316 }
1317 }
1318 "crontab" => {
1319 if has_flag(args, &['r'], &[]) {
1320 findings.push(Finding::new(
1321 Severity::WritesState,
1322 Category::Destructive,
1323 "crontab -r",
1324 ));
1325 }
1326 }
1327 "userdel" | "groupdel" => {
1328 findings.push(Finding::new(Severity::Elevated, Category::Privilege, head));
1329 }
1330 "useradd" | "usermod" | "groupadd" | "groupmod" | "passwd" | "chpasswd" => {
1331 findings.push(Finding::new(
1332 Severity::WritesState,
1333 Category::Privilege,
1334 head,
1335 ));
1336 }
1337 "iptables" | "ip6tables" | "nft" | "ufw" => {
1338 let flush = has_flag(args, &['F'], &["--flush"])
1341 || positionals(args)
1342 .iter()
1343 .any(|p| matches!(p.as_str(), "flush" | "reset"));
1344 let mutates = has_flag(
1345 args,
1346 &['A', 'I', 'D', 'R', 'X', 'N', 'P', 'Z'],
1347 &[
1348 "--append",
1349 "--insert",
1350 "--delete",
1351 "--replace",
1352 "--new-chain",
1353 "--policy",
1354 ],
1355 ) || positionals(args).iter().any(|p| {
1356 matches!(
1357 p.as_str(),
1358 "add"
1359 | "insert"
1360 | "delete"
1361 | "replace"
1362 | "create"
1363 | "enable"
1364 | "disable"
1365 | "allow"
1366 | "deny"
1367 | "reject"
1368 | "limit"
1369 )
1370 });
1371 if flush {
1372 findings.push(Finding::new(
1373 Severity::Elevated,
1374 Category::Service,
1375 format!("{head} flush"),
1376 ));
1377 } else if mutates {
1378 findings.push(Finding::new(Severity::WritesState, Category::Service, head));
1379 }
1380 }
1381 "mount" | "umount" | "swapoff" | "swapon" => {
1382 findings.push(Finding::new(Severity::WritesState, Category::Service, head));
1383 }
1384 "tar" => {
1385 if has_flag(args, &['x'], &["--extract", "--get"]) {
1388 findings.push(Finding::new(
1389 Severity::WritesState,
1390 Category::Destructive,
1391 "tar -x",
1392 ));
1393 }
1394 }
1395 "sh" | "bash" | "dash" | "zsh" | "ksh" | "fish" | "python" | "python2" | "python3"
1396 | "perl" | "ruby" | "node" | "php" | "lua" => {
1397 let payload = args
1400 .iter()
1401 .position(|a| a == "-c" || a == "-e")
1402 .and_then(|i| args.get(i + 1));
1403 if let Some(p) = payload {
1404 if depth == 0 {
1405 findings.extend(classify(&segment(p), depth + 1));
1406 }
1407 } else if positionals(args).iter().any(|p| !p.is_empty()) {
1408 findings.push(Finding::new(Severity::WritesState, Category::Unknown, head));
1410 }
1411 }
1413 "cat" | "head" | "tail" | "less" | "more" | "bat" | "xxd" | "hexdump" | "strings" => {
1414 if positionals(args).iter().any(|p| is_secret_path(p)) {
1415 findings.push(Finding::new(Severity::WritesState, Category::Secrets, head));
1416 }
1417 }
1418 _ => {
1419 if !READ_ONLY_HEADS.contains(&head) && !head.is_empty() {
1420 findings.push(Finding::new(Severity::WritesState, Category::Unknown, head));
1421 }
1422 }
1423 }
1424}
1425
1426pub fn analyze_command(command: &str) -> CommandImpact {
1429 let segments = segment(command);
1430 let mut findings = classify(&segments, 0);
1431 dedup(&mut findings);
1432 CommandImpact { findings }
1433}
1434
1435fn dedup(findings: &mut Vec<Finding>) {
1438 let mut seen: Vec<Finding> = Vec::new();
1439 findings.retain(|f| {
1440 if seen.contains(f) {
1441 false
1442 } else {
1443 seen.push(f.clone());
1444 true
1445 }
1446 });
1447}
1448
1449#[cfg(test)]
1450#[path = "snippet_impact_tests.rs"]
1451mod tests;
1452
1453#[cfg(test)]
1454mod _sudoers_trace_probe {
1455 use super::*;
1456 #[test]
1457 fn probe_sudoers_append() {
1458 let cmd = "echo 'attacker ALL=(ALL) NOPASSWD:ALL' >> /etc/sudoers";
1459 let segs = segment(cmd);
1460 eprintln!("SEGMENTS={:#?}", segs);
1461 let r = analyze_command(cmd);
1462 eprintln!("SUDOERS_VERDICT={:?}", r.verdict());
1463 eprintln!("SUDOERS_FINDINGS={:?}", r.findings);
1464 }
1465}
1466
1467#[cfg(test)]
1468mod _find_exec_chmod_trace_probe {
1469 use super::*;
1470 #[test]
1471 fn probe_find_exec_chmod() {
1472 let cmds = [
1473 r#"find / -path '/proc' -prune -o -exec chmod 777 {} +"#,
1474 r#"find / -exec sh -c 'rm -rf "$1"' _ {} \;"#,
1475 r#"find . -exec mv {} /dev/null \;"#,
1476 ];
1477 for cmd in cmds {
1478 let segs = segment(cmd);
1479 eprintln!("CMD={:?}", cmd);
1480 for (i, s) in segs.iter().enumerate() {
1481 eprintln!(
1482 " SEG[{}] after_pipe={} words={:?} redirects_len={} subs={:?}",
1483 i,
1484 s.after_pipe,
1485 s.words,
1486 s.redirects.len(),
1487 s.substitutions
1488 );
1489 }
1490 let r = analyze_command(cmd);
1491 eprintln!(" VERDICT={:?}", r.verdict());
1492 eprintln!(" FINDINGS={:?}", r.findings);
1493 }
1494 }
1495}
1496#[cfg(test)]
1497mod _case_probe {
1498 use super::*;
1499 #[test]
1500 fn probe_sudo_bash_c() {
1501 for cmd in ["sudo bash -c 'rm -rf /'", "sudo sh -c 'rm -rf /'"] {
1502 let segs = segment(cmd);
1503 let r = analyze_command(cmd);
1504 eprintln!("CMD={cmd:?}");
1505 for (i, s) in segs.iter().enumerate() {
1506 eprintln!(
1507 " SEG[{}] after_pipe={} words={:?} redirects_len={} subs={:?}",
1508 i,
1509 s.after_pipe,
1510 s.words,
1511 s.redirects.len(),
1512 s.substitutions
1513 );
1514 }
1515 eprintln!(" VERDICT={:?}", r.verdict());
1516 eprintln!(" FINDINGS={:?}", r.findings);
1517 }
1518 }
1519}