use crate::parse::{Token, WordSet};
use crate::verdict::{SafetyLevel, Verdict};
static FIND_SAFE_STANDALONE: WordSet = WordSet::new(&[
"--help", "--version",
"-H", "-L", "-P",
"-a", "-and", "-d", "-daystart", "-depth",
"-empty", "-executable", "-false", "-follow", "-help",
"-ignore_readdir_race", "-ls", "-mount",
"-nogroup", "-noignore_readdir_race", "-noleaf", "-not", "-nouser", "-nowarn",
"-o", "-or",
"-print", "-print0", "-prune", "-quit", "-readable", "-true", "-version",
"-warn", "-writable", "-xdev",
]);
static FIND_SAFE_VALUED: WordSet = WordSet::new(&[
"-D",
"-amin", "-anewer", "-atime",
"-cmin", "-cnewer", "-context", "-ctime",
"-fstype",
"-gid", "-group",
"-ilname", "-iname", "-inum", "-ipath", "-iregex", "-iwholename",
"-links", "-lname",
"-maxdepth", "-mindepth", "-mmin", "-mtime",
"-name",
"-path", "-perm", "-printf",
"-regex", "-regextype",
"-samefile", "-size",
"-type",
"-uid", "-used", "-user",
"-wholename", "-xtype",
]);
pub(in crate::handlers::coreutils) fn is_safe_find(tokens: &[Token]) -> Verdict {
let bases: Vec<&str> = {
let mut i = 1;
while i < tokens.len() {
match tokens[i].as_str() {
"-H" | "-L" | "-P" => i += 1,
"-D" => i += 2,
s if s.starts_with("-O") => i += 1,
_ => break,
}
}
let leading: Vec<&str> = tokens[i..]
.iter()
.take_while(|t| !t.as_str().starts_with('-'))
.map(Token::as_str)
.collect();
if leading.is_empty() { vec!["."] } else { leading }
};
let mut level = SafetyLevel::Inert;
let mut i = 1;
while i < tokens.len() {
let s = tokens[i].as_str();
if s == "-exec" || s == "-execdir" {
let cmd_start = i + 1;
let cmd_end = tokens[cmd_start..]
.iter()
.position(|t| *t == ";" || *t == "+")
.map(|p| cmd_start + p)
.unwrap_or(tokens.len());
if cmd_start >= cmd_end {
return Verdict::Denied;
}
for base in &bases {
let bound = format!("{}/f", base.trim_end_matches('/'));
let exec_words: Vec<String> = tokens[cmd_start..cmd_end]
.iter()
.map(|t| t.as_str().replace("{}", &bound))
.collect();
match crate::command_verdict(&shell_words::join(&exec_words)) {
Verdict::Denied => return Verdict::Denied,
Verdict::Allowed(l) => level = level.max(l),
}
}
i = cmd_end + 1;
continue;
}
if !s.starts_with('-') {
i += 1;
continue;
}
if s.starts_with("-O") {
i += 1;
continue;
}
if FIND_SAFE_VALUED.contains(&tokens[i]) || s.starts_with("-newer") {
i += 2;
continue;
}
if FIND_SAFE_STANDALONE.contains(&tokens[i]) {
i += 1;
continue;
}
return Verdict::Denied;
}
Verdict::Allowed(level)
}
pub(in crate::handlers::coreutils) fn dispatch(cmd: &str, tokens: &[Token]) -> Option<Verdict> {
match cmd {
"find" => Some(is_safe_find(tokens)),
_ => None,
}
}
pub(in crate::handlers::coreutils) fn command_docs() -> Vec<crate::docs::CommandDoc> {
vec![
crate::docs::CommandDoc::handler("find",
"https://www.gnu.org/software/findutils/manual/html_mono/find.html",
"Read-only predicates and actions allowed (tests like -name/-type/-size, -print/-ls/-prune, \
operators, positional and global options). -exec/-execdir allowed when the executed \
command is itself safe (each `{}` binds to the traversal path).",
"fs"),
]
}
#[cfg(test)]
mod tests {
use crate::is_safe_command;
fn check(cmd: &str) -> bool { is_safe_command(cmd) }
safe! {
find_name: "find . -name '*.rb'",
find_type_name: "find . -type f -name '*.py'",
find_maxdepth: "find /tmp -maxdepth 2",
find_print: "find . -name '*.log' -print",
find_print0: "find . -name '*.log' -print0",
find_exec_grep_l: "find . -name '*.rb' -exec grep -l pattern {} \\;",
find_exec_grep_l_plus: "find . -name '*.rb' -exec grep -l pattern {} +",
find_exec_cat: "find . -exec cat {} \\;",
find_execdir_cat: "find . -execdir cat {} \\;",
find_execdir_grep: "find . -execdir grep pattern {} \\;",
find_exec_grep_safe: "find . -name '*.py' -exec grep pattern {} +",
find_exec_nested_bash_safe: "find . -exec bash -c 'git status' \\;",
find_exec_rm: "find . -exec rm {} \\;",
find_exec_rm_rf: "find . -exec rm -rf {} +",
find_execdir_unsafe: "find . -execdir rm {} \\;",
find_size_negative: "find . -size -1M",
find_mtime_negative: "find . -mtime -7 -type f",
find_perm_dash: "find . -perm -644",
find_newer: "find . -newer ref.txt",
find_newermt: "find . -newermt 2024-01-01",
find_mindepth_maxdepth: "find . -mindepth 1 -maxdepth 3 -type d",
find_empty: "find . -empty",
find_not_operator: "find . -not -name '*.tmp'",
find_or_operator: "find . -name '*.rb' -o -name '*.py'",
find_printf: "find . -printf '%p\\n'",
find_help: "find --help",
find_name_dash_delete_value: "find . -name -delete",
}
denied! {
find_delete_denied: "find . -name '*.tmp' -delete",
find_global_opt_L_system: "find -L /etc -exec rm -rf {} \\;",
find_global_opt_P_system: "find -P / -exec rm {} \\;",
find_global_opt_O_system: "find -O3 /etc -exec rm {} \\;",
find_global_opt_D_system: "find -D tree /etc -exec rm {} \\;",
find_ok_denied: "find . -ok rm {} \\;",
find_okdir_denied: "find . -okdir rm {} \\;",
find_exec_nested_bash_chain_denied: "find . -exec bash -c 'ls && rm -rf /' \\;",
find_type_delete_denied: "find . -type f -name '*.bak' -delete",
find_fprint_denied: "find . -fprint /tmp/list.txt",
find_fprint0_denied: "find . -fprint0 /tmp/list.txt",
find_fls_denied: "find . -fls /tmp/list.txt",
find_fprintf_denied: "find . -fprintf /tmp/list.txt '%p'",
find_unknown_primary_denied: "find . -frobnicate",
find_delete_after_valued_denied: "find . -mtime -7 -delete",
find_unknown_action_after_test_denied: "find . -type f -flushcache",
}
}