use regex::Regex;
use std::sync::LazyLock;
static EGP: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"^(?:[A-Za-z_][A-Za-z0-9_]*\+?=[^\s]*\s+)*\\?(?:[^\s=]*/)?(rm|rmdir)(?:\s|$)")
.expect("egp")
});
static ZHP: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r#"^"?\$(?:\{[A-Za-z_][A-Za-z0-9_]*\}|[A-Za-z_][A-Za-z0-9_]*)"?/(?:\*|\$|/|["']|$)"#)
.expect("Zhp")
});
static RM_WORD: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\brm(?:dir)?\b").expect("rm_word"));
static REDIR_START: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^[\d&]*[<>]").expect("redir"));
static REDIR_OP: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^(?:[0-9]+|&)?(?:>>?[|&]?|<<?<?|<>)$").expect("redir_op"));
static LINE_CONT: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\\\r?\n").expect("line_cont"));
static BACKTICKS: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"`[^`]*`").expect("backticks"));
static DOLLAR_PAREN: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\$\([^()]*\)").expect("dpar"));
static PLAIN_PAREN: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\([^()]*\)").expect("ppar"));
static CLAUSE_SPLIT: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"[;|\n\r]|&&").expect("clause"));
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DangerousRm {
pub command: &'static str,
pub target: String,
}
#[must_use]
pub fn is_dangerous_rm(command: &str) -> bool {
dangerous_rm(command).is_some()
}
#[must_use]
pub fn dangerous_rm(command: &str) -> Option<DangerousRm> {
if !command.contains('$') || !RM_WORD.is_match(command) {
return None;
}
let n = LINE_CONT.replace_all(command, " ");
let n = BACKTICKS.replace_all(&n, " ");
let mut n = n.trim_start().to_string();
while n.starts_with('(') || n.starts_with('{') {
n = n[1..].trim_start().to_string();
}
n = strip_paren_groups(&n);
n = amp_to_semicolon(&n);
for clause in CLAUSE_SPLIT.split(&n) {
let o = strip_leading_keywords(clause.trim_start());
let Some(caps) = EGP.captures(o) else {
continue;
};
let command_word: &'static str = if &caps[1] == "rmdir" { "rmdir" } else { "rm" };
let rest = &o[caps.get(0).map_or(0, |m| m.end())..];
let args: Vec<&str> = rest.split_whitespace().collect();
let mut l = 0usize;
while l < args.len() {
let c = args[l].trim_end_matches([')', ']', '}']);
if c.is_empty() || c.starts_with('-') || c.starts_with('\'') {
l += 1;
continue;
}
if REDIR_START.is_match(c) {
if REDIR_OP.is_match(c) {
l += 1; }
l += 1;
continue;
}
if ZHP.is_match(c) {
return Some(DangerousRm {
command: command_word,
target: c.to_string(),
});
}
l += 1;
}
}
None
}
fn strip_paren_groups(s: &str) -> String {
let mut n = s.to_string();
loop {
let prev = n.clone();
n = DOLLAR_PAREN.replace_all(&n, " ").into_owned();
n = remove_plain_groups(&n);
if n == prev {
break;
}
}
n
}
fn remove_plain_groups(s: &str) -> String {
let bytes = s.as_bytes();
let mut out = String::with_capacity(s.len());
let mut last = 0;
for m in PLAIN_PAREN.find_iter(s) {
let start = m.start();
let preceded_by_dollar = start > 0 && bytes[start - 1] == b'$';
out.push_str(&s[last..start]);
if preceded_by_dollar {
out.push_str(m.as_str()); } else {
out.push(' ');
}
last = m.end();
}
out.push_str(&s[last..]);
out
}
fn amp_to_semicolon(s: &str) -> String {
let b = s.as_bytes();
let mut out: Vec<u8> = Vec::with_capacity(b.len());
for i in 0..b.len() {
if b[i] == b'&' {
let prev_bad = i > 0 && matches!(b[i - 1], b'<' | b'>' | b'&');
let next_bad = i + 1 < b.len() && matches!(b[i + 1], b'<' | b'>' | b'&');
if !prev_bad && !next_bad {
out.push(b';');
continue;
}
}
out.push(b[i]);
}
String::from_utf8(out).unwrap_or_else(|_| s.to_string())
}
fn strip_leading_keywords(mut s: &str) -> &str {
const KW: &[&str] = &["do", "then", "else", "elif", "if", "while", "until", "time"];
s = s.trim_start();
loop {
if let Some(rest) = s.strip_prefix('!') {
s = rest.trim_start();
continue;
}
let mut stripped = false;
for kw in KW {
if let Some(rest) = s.strip_prefix(kw) {
if rest.is_empty() || rest.starts_with(|c: char| c.is_whitespace()) {
s = rest.trim_start();
stripped = true;
break;
}
}
}
if !stripped {
return s;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn redirect_target_token_is_skipped_not_flagged() {
assert!(dangerous_rm("rm -r $X > $TMP/").is_none());
assert!(dangerous_rm("rm -r $X 2>$TMP/").is_none());
}
#[test]
fn amp_to_semicolon_boundary_forms() {
assert_eq!(amp_to_semicolon("a & b"), "a ; b");
assert_eq!(amp_to_semicolon("a&"), "a;"); assert_eq!(amp_to_semicolon("&b"), ";b"); assert_eq!(amp_to_semicolon("a&&b"), "a&&b");
assert_eq!(amp_to_semicolon("2>&1"), "2>&1");
assert_eq!(amp_to_semicolon("<&0"), "<&0");
assert_eq!(amp_to_semicolon("a&>x"), "a&>x");
}
#[test]
fn plain_group_removal_edges() {
assert_eq!(remove_plain_groups("(a b) x"), " x");
assert_eq!(remove_plain_groups("a$(b) (c) d"), "a$(b) d");
}
#[test]
fn flags_the_real_escalation_command() {
let cmd = r#"SCRATCH="/tmp/x"
for f in a.txt b.txt; do
[ -f "$SCRATCH/$f" ] && rm -f "$SCRATCH/$f" && echo " shredded $f"
done"#;
let d = dangerous_rm(cmd).expect("must flag the teardown rm");
assert_eq!(d.command, "rm");
assert!(d.target.contains("$SCRATCH/$f"), "target: {}", d.target);
}
#[test]
fn zhp_target_forms_match_cc() {
for t in [
r#"rm -rf "$SCRATCH/$f""#, r"rm $DIR/*", r"rm ${DIR}/$x", r#"rm "$X"/"#, r"rmdir $D/", ] {
assert!(is_dangerous_rm(t), "should flag: {t}");
}
}
#[test]
fn lexical_boundary_var_slash_literal_is_not_dangerous() {
assert!(!is_dangerous_rm("rm $TMP/build"));
assert!(!is_dangerous_rm("rm ${DIR}/sub"));
assert!(is_dangerous_rm("rm $TMP/*")); }
#[test]
fn keyword_attached_rm_is_recovered() {
assert!(is_dangerous_rm("for f in a; do rm -rf $TMP/$f; done"));
assert!(is_dangerous_rm("if true; then rm $TMP/*; fi"));
}
#[test]
fn safe_commands_are_not_flagged() {
assert!(!is_dangerous_rm("rm -rf /tmp/build"));
assert!(!is_dangerous_rm("echo $HOME/x")); assert!(!is_dangerous_rm("rm -rf ./dist")); assert!(!is_dangerous_rm("rm $FILE")); assert!(!is_dangerous_rm(
r#"docker exec "$node" ctr -n k8s.io images rm docker.io/library/img 2>&1"#
));
}
#[test]
fn empty_var_is_flagged_lexically_like_cc() {
let cmd = "SCRATCH=/tmp/real\nrm -rf $SCRATCH/*";
assert!(is_dangerous_rm(cmd));
}
#[test]
fn redirects_and_flags_are_skipped_then_target_found() {
assert!(is_dangerous_rm("rm -f 2>/dev/null $TMP/*"));
assert!(is_dangerous_rm("rm -f > log $TMP/*"));
}
#[test]
fn amp_to_semicolon_preserves_operators() {
assert_eq!(amp_to_semicolon("a & b"), "a ; b");
assert_eq!(amp_to_semicolon("a && b"), "a && b");
assert_eq!(amp_to_semicolon("x 2>&1"), "x 2>&1");
assert_eq!(amp_to_semicolon("x >&2"), "x >&2");
}
#[test]
fn paren_groups_stripped() {
assert!(is_dangerous_rm("(rm -rf $TMP/*)"));
assert!(!is_dangerous_rm("echo $(rm $TMP/*)")); }
}