Skip to main content

tirith_core/rules/
command.rs

1use 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
13/// Run command-shape rules.
14pub fn check(input: &str, shell: ShellType) -> Vec<Finding> {
15    let mut findings = Vec::new();
16    let segments = tokenize::tokenize(input, shell);
17
18    // Check for pipe-to-interpreter patterns
19    if PIPE_TO_INTERPRETER.is_match(input) {
20        check_pipe_to_interpreter(&segments, &mut findings);
21    }
22
23    // Check for insecure TLS flags in source commands
24    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 for dotfile overwrites
36    check_dotfile_overwrite(&segments, &mut findings);
37
38    // Check for archive extraction to sensitive paths
39    check_archive_extract(&segments, &mut findings);
40
41    findings
42}
43
44/// Resolve the effective interpreter from a segment.
45/// If the command is `sudo`, `env`, or an absolute path to one of them,
46/// look at the first non-flag argument to find the real interpreter.
47fn 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 the command is sudo, env, or a path, check the first non-flag arg
54        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                // If it's not an interpreter, it's the actual command (not an interpreter)
69                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                    // Find the source segment
85                    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        // Check for redirects to dotfiles
124        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                // Check if extracting to a sensitive directory
151                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}