use super::classify::*;
use super::lexer::*;
use super::tables::*;
pub(crate) fn is_sensitive_write_target(path: &str) -> bool {
let p = path.trim_matches(['"', '\'']);
if is_safe_device_write(p) {
return false;
}
const SENSITIVE_PREFIXES: &[&str] = &[
"/etc/",
"/boot/",
"/sys/",
"/dev/",
"/usr/",
"/bin/",
"/sbin/",
"/lib",
"/var/spool/cron",
];
if SENSITIVE_PREFIXES.iter().any(|pre| p.starts_with(pre)) {
return true;
}
if p.contains("/.ssh/") || p.contains("/cron") {
return true;
}
const SENSITIVE_SUFFIXES: &[&str] = &[
"/.bashrc",
"/.zshrc",
"/.profile",
"/.bash_profile",
"/.zprofile",
"/authorized_keys",
];
if SENSITIVE_SUFFIXES.iter().any(|suf| p.ends_with(suf)) {
return true;
}
p.contains("\\windows\\") || p.contains("\\system32\\") || p.contains("\\startup\\")
}
pub(crate) fn ps_param(tok: &str, full: &str) -> bool {
tok.strip_prefix('-')
.is_some_and(|p| !p.is_empty() && full.starts_with(&p.to_ascii_lowercase()))
}
pub(crate) fn windows_recursive_delete(head: &str, rest: &[String]) -> bool {
if !matches!(
head,
"remove-item" | "ri" | "del" | "erase" | "rd" | "rmdir"
) {
return false;
}
let recursive = rest.iter().any(|a| a == "/s" || ps_param(a, "recurse"));
recursive && rest.iter().any(|a| is_dangerous_root(a))
}
pub(crate) fn contains_destructive_pattern(command: &str) -> bool {
destructive_with_depth(command, 0)
}
pub(crate) fn destructive_with_depth(command: &str, depth: u8) -> bool {
let lower = command
.to_ascii_lowercase()
.replace("${ifs}", " ")
.replace("$ifs", " ");
let nospace: String = lower.chars().filter(|c| !c.is_whitespace()).collect();
if is_fork_bomb(&nospace) {
return true;
}
let tokens = tokenize(&lower);
for (i, tok) in tokens.iter().enumerate() {
let head = basename(tok);
let head = head.strip_suffix(".exe").unwrap_or(head);
let rest = &tokens[i + 1..];
if head.starts_with("mkfs") {
return true;
}
let recursive_on_root =
flag_present(rest, 'r') && rest.iter().any(|a| is_dangerous_root(a));
if matches!(head, "rm" | "chmod" | "chown") && recursive_on_root {
return true;
}
if windows_recursive_delete(head, rest) {
return true;
}
if head == "format"
&& rest
.iter()
.any(|a| is_dangerous_root(a) || a.ends_with(':'))
{
return true;
}
if head == "dd" && rest.iter().any(|a| a.starts_with("of=/dev/")) {
return true;
}
if SHELL_INTERPRETERS.contains(&head)
&& let Some(pos) = rest.iter().position(|a| a == "-c")
&& let Some(script) = rest.get(pos + 1)
{
if depth >= 3 || destructive_with_depth(script, depth + 1) {
return true;
}
}
if matches!(head, "pwsh" | "powershell")
&& let Some(pos) = rest.iter().position(|a| ps_param(a, "command"))
&& let Some(script) = rest.get(pos + 1)
&& (depth >= 3 || destructive_with_depth(script, depth + 1))
{
return true;
}
}
let ws: Vec<String> = lower.split_whitespace().map(str::to_string).collect();
for (i, tok) in ws.iter().enumerate() {
let head = basename(tok);
let head = head.strip_suffix(".exe").unwrap_or(head);
if windows_recursive_delete(head, &ws[i + 1..]) {
return true;
}
}
for (i, tok) in tokens.iter().enumerate() {
if redirect_target_after(tok).is_some()
&& let Some(target) = redirect_write_target(&tokens, i)
&& is_sensitive_write_target(target)
{
return true;
}
if basename(tok) == "tee"
&& let Some(target) = tokens[i + 1..].iter().find(|t| !t.starts_with('-'))
&& is_sensitive_write_target(target.trim_end_matches([';', '&', '|']))
{
return true;
}
}
if tokens.iter().any(|t| basename(t) == "git")
&& tokens.iter().any(|t| t == "reset")
&& tokens.iter().any(|t| t == "--hard")
{
return true;
}
if depth < 3 {
for body in extract_substitutions(&lower) {
if destructive_with_depth(&body, depth + 1) {
return true;
}
}
} else if !extract_substitutions(&lower).is_empty() {
return true;
}
false
}
pub(crate) fn destructive_scan_segments(command: &str) -> Vec<String> {
fn collect(command: &str, depth: u8, out: &mut Vec<String>) {
const MAX_BODY_DEPTH: u8 = 3;
let split = split_command(command);
out.extend(split.segments);
if depth >= MAX_BODY_DEPTH {
return;
}
for hd in split.heredocs {
collect(&hd.body, depth + 1, out);
}
for body in extract_substitutions_quote_blind(command) {
collect(&body, depth + 1, out);
}
}
let mut out = Vec::new();
collect(command, 0, &mut out);
out
}
#[must_use]
pub fn is_destructive_command(command: &str) -> bool {
if contains_destructive_pattern(command) {
return true;
}
let mut saw_downloader = false;
let mut saw_bare_shell = false;
for seg in destructive_scan_segments(command) {
if contains_destructive_pattern(&seg) {
return true;
}
let tokens = tokenize(&seg.to_ascii_lowercase());
let Some(head) = tokens.first().map(|t| basename(t)) else {
continue;
};
match head {
"nc" | "ncat" | "netcat" if flag_present(&tokens[1..], 'l') => return true,
"socat"
if tokens[1..]
.iter()
.any(|a| a.contains("-listen:") || a.contains("-listen,")) =>
{
return true;
},
"curl" | "wget" | "fetch" => saw_downloader = true,
h if SHELL_INTERPRETERS.contains(&h)
&& !tokens[1..].iter().any(|a| !a.starts_with('-')) =>
{
saw_bare_shell = true;
},
_ => {},
}
}
saw_downloader && saw_bare_shell
}