tirith_core/rules/
command.rs1use once_cell::sync::Lazy;
2use regex::Regex;
3
4use crate::tokenize::{self, ShellType};
5use crate::verdict::{Evidence, Finding, RuleId, Severity};
6
7static PIPE_TO_INTERPRETER: Lazy<Regex> = Lazy::new(|| {
8 Regex::new(
9 r"(?i)\|\s*(?:&\s*)?(?:sudo\s+|env\s+|/\S*/?)*(sh|bash|zsh|dash|ksh|python|python3|node|perl|ruby|php|iex|invoke-expression)\b"
10 ).unwrap()
11});
12
13pub fn check(input: &str, shell: ShellType) -> Vec<Finding> {
15 let mut findings = Vec::new();
16 let segments = tokenize::tokenize(input, shell);
17
18 if PIPE_TO_INTERPRETER.is_match(input) {
20 check_pipe_to_interpreter(&segments, &mut findings);
21 }
22
23 for segment in &segments {
25 if let Some(ref cmd) = segment.command {
26 let cmd_base = cmd.rsplit('/').next().unwrap_or(cmd).to_lowercase();
27 if is_source_command(&cmd_base) {
28 let tls_findings =
29 crate::rules::transport::check_insecure_flags(&segment.args, true);
30 findings.extend(tls_findings);
31 }
32 }
33 }
34
35 check_dotfile_overwrite(&segments, &mut findings);
37
38 check_archive_extract(&segments, &mut findings);
40
41 findings
42}
43
44fn resolve_interpreter_name(seg: &tokenize::Segment) -> Option<String> {
48 if let Some(ref cmd) = seg.command {
49 let cmd_base = cmd.rsplit('/').next().unwrap_or(cmd).to_lowercase();
50 if is_interpreter(&cmd_base) {
51 return Some(cmd_base);
52 }
53 if cmd_base == "sudo" || cmd_base == "env" {
55 for arg in &seg.args {
56 let arg_trimmed = arg.trim();
57 if arg_trimmed.starts_with('-') {
58 continue;
59 }
60 let arg_base = arg_trimmed
61 .rsplit('/')
62 .next()
63 .unwrap_or(arg_trimmed)
64 .to_lowercase();
65 if is_interpreter(&arg_base) {
66 return Some(arg_base);
67 }
68 break;
70 }
71 }
72 }
73 None
74}
75
76fn check_pipe_to_interpreter(segments: &[tokenize::Segment], findings: &mut Vec<Finding>) {
77 for (i, seg) in segments.iter().enumerate() {
78 if i == 0 {
79 continue;
80 }
81 if let Some(sep) = &seg.preceding_separator {
82 if sep == "|" || sep == "|&" {
83 if let Some(interpreter) = resolve_interpreter_name(seg) {
84 if i > 0 {
86 let source = &segments[i - 1];
87 let source_cmd = source.command.as_deref().unwrap_or("unknown").to_string();
88 let source_base = source_cmd
89 .rsplit('/')
90 .next()
91 .unwrap_or(&source_cmd)
92 .to_lowercase();
93
94 let rule_id = match source_base.as_str() {
95 "curl" => RuleId::CurlPipeShell,
96 "wget" => RuleId::WgetPipeShell,
97 _ => RuleId::PipeToInterpreter,
98 };
99
100 let display_cmd = seg.command.as_deref().unwrap_or(&interpreter);
101
102 findings.push(Finding {
103 rule_id,
104 severity: Severity::High,
105 title: format!("Pipe to interpreter: {source_cmd} | {display_cmd}"),
106 description: format!(
107 "Command pipes output from '{source_base}' directly to interpreter '{interpreter}'. Downloaded content will be executed without inspection."
108 ),
109 evidence: vec![Evidence::CommandPattern {
110 pattern: "pipe to interpreter".to_string(),
111 matched: format!("{} | {}", source.raw, seg.raw),
112 }],
113 });
114 }
115 }
116 }
117 }
118 }
119}
120
121fn check_dotfile_overwrite(segments: &[tokenize::Segment], findings: &mut Vec<Finding>) {
122 for segment in segments {
123 let raw = &segment.raw;
125 if (raw.contains("> ~/.")
126 || raw.contains("> $HOME/.")
127 || raw.contains(">> ~/.")
128 || raw.contains(">> $HOME/."))
129 && !raw.contains("> /dev/null")
130 {
131 findings.push(Finding {
132 rule_id: RuleId::DotfileOverwrite,
133 severity: Severity::High,
134 title: "Dotfile overwrite detected".to_string(),
135 description: "Command redirects output to a dotfile in the home directory, which could overwrite shell configuration".to_string(),
136 evidence: vec![Evidence::CommandPattern {
137 pattern: "redirect to dotfile".to_string(),
138 matched: raw.clone(),
139 }],
140 });
141 }
142 }
143}
144
145fn check_archive_extract(segments: &[tokenize::Segment], findings: &mut Vec<Finding>) {
146 for segment in segments {
147 if let Some(ref cmd) = segment.command {
148 let cmd_base = cmd.rsplit('/').next().unwrap_or(cmd).to_lowercase();
149 if cmd_base == "tar" || cmd_base == "unzip" || cmd_base == "7z" {
150 let raw = &segment.raw;
152 let sensitive_targets = [
153 "-C /",
154 "-C ~/",
155 "-C $HOME/",
156 "-d /",
157 "-d ~/",
158 "-d $HOME/",
159 "> ~/.",
160 ">> ~/.",
161 ];
162 for target in &sensitive_targets {
163 if raw.contains(target) {
164 findings.push(Finding {
165 rule_id: RuleId::ArchiveExtract,
166 severity: Severity::Medium,
167 title: "Archive extraction to sensitive path".to_string(),
168 description: format!(
169 "Archive command '{cmd_base}' extracts to a potentially sensitive location"
170 ),
171 evidence: vec![Evidence::CommandPattern {
172 pattern: "archive extract".to_string(),
173 matched: raw.clone(),
174 }],
175 });
176 return;
177 }
178 }
179 }
180 }
181 }
182}
183
184fn is_source_command(cmd: &str) -> bool {
185 matches!(
186 cmd,
187 "curl"
188 | "wget"
189 | "fetch"
190 | "scp"
191 | "rsync"
192 | "iwr"
193 | "irm"
194 | "invoke-webrequest"
195 | "invoke-restmethod"
196 )
197}
198
199fn is_interpreter(cmd: &str) -> bool {
200 matches!(
201 cmd,
202 "sh" | "bash"
203 | "zsh"
204 | "dash"
205 | "ksh"
206 | "python"
207 | "python3"
208 | "node"
209 | "perl"
210 | "ruby"
211 | "php"
212 | "iex"
213 | "invoke-expression"
214 )
215}