pub fn command_might_be_dangerous(command: &[String]) -> bool {
#[cfg(windows)]
{
if crate::command_safety::windows::is_dangerous_command_windows(command) {
return true;
}
}
if is_dangerous_to_call_with_exec(command) {
return true;
}
if command.len() >= 3
&& (command[0] == "bash" || command[0] == "sh" || command[0] == "zsh")
&& (command[1] == "-c" || command[1] == "-lc" || command[1] == "-ilc")
{
let script = &command[2];
if let Ok(sub_commands) = crate::command_safety::shell_parser::parse_shell_commands(script)
{
for sub_cmd in sub_commands {
if command_might_be_dangerous(&sub_cmd) {
return true;
}
}
}
}
false
}
fn is_git_global_option_with_value(arg: &str) -> bool {
matches!(
arg,
"-C" | "-c"
| "--config-env"
| "--exec-path"
| "--git-dir"
| "--namespace"
| "--super-prefix"
| "--work-tree"
)
}
fn is_git_global_option_with_inline_value(arg: &str) -> bool {
matches!(
arg,
s if s.starts_with("--config-env=")
|| s.starts_with("--exec-path=")
|| s.starts_with("--git-dir=")
|| s.starts_with("--namespace=")
|| s.starts_with("--super-prefix=")
|| s.starts_with("--work-tree=")
) || ((arg.starts_with("-C") || arg.starts_with("-c")) && arg.len() > 2)
}
pub(crate) fn git_global_option_requires_prompt(arg: &str) -> bool {
matches!(
arg,
"-c" | "--config-env"
| "--exec-path"
| "--git-dir"
| "--namespace"
| "--super-prefix"
| "--work-tree"
) || matches!(
arg,
s if (s.starts_with("-c") && s.len() > 2)
|| s.starts_with("--config-env=")
|| s.starts_with("--exec-path=")
|| s.starts_with("--git-dir=")
|| s.starts_with("--namespace=")
|| s.starts_with("--super-prefix=")
|| s.starts_with("--work-tree=")
)
}
pub(crate) fn find_git_subcommand<'a>(
command: &'a [String],
subcommands: &[&str],
) -> Option<(usize, &'a str)> {
let cmd0 = command.first().map(String::as_str)?;
if !cmd0.ends_with("git") {
return None;
}
let mut skip_next = false;
for (idx, arg) in command.iter().enumerate().skip(1) {
if skip_next {
skip_next = false;
continue;
}
let arg = arg.as_str();
if is_git_global_option_with_inline_value(arg) {
continue;
}
if is_git_global_option_with_value(arg) {
skip_next = true;
continue;
}
if arg == "--" || arg.starts_with('-') {
continue;
}
if subcommands.contains(&arg) {
return Some((idx, arg));
}
return None;
}
None
}
fn short_flag_group_contains(arg: &str, target: char) -> bool {
arg.starts_with('-') && !arg.starts_with("--") && arg.chars().skip(1).any(|c| c == target)
}
fn git_branch_is_delete(branch_args: &[String]) -> bool {
branch_args.iter().map(String::as_str).any(|arg| {
matches!(arg, "-d" | "-D" | "--delete")
|| arg.starts_with("--delete=")
|| short_flag_group_contains(arg, 'd')
|| short_flag_group_contains(arg, 'D')
})
}
fn git_push_is_dangerous(push_args: &[String]) -> bool {
push_args.iter().map(String::as_str).any(|arg| {
matches!(
arg,
"--force" | "--force-with-lease" | "--force-if-includes" | "--delete" | "-f" | "-d"
) || arg.starts_with("--force-with-lease=")
|| arg.starts_with("--force-if-includes=")
|| arg.starts_with("--delete=")
|| short_flag_group_contains(arg, 'f')
|| short_flag_group_contains(arg, 'd')
|| git_push_refspec_is_dangerous(arg)
})
}
fn git_push_refspec_is_dangerous(arg: &str) -> bool {
(arg.starts_with('+') || arg.starts_with(':')) && arg.len() > 1
}
fn git_clean_is_force(clean_args: &[String]) -> bool {
clean_args.iter().map(String::as_str).any(|arg| {
matches!(arg, "--force" | "-f")
|| arg.starts_with("--force=")
|| short_flag_group_contains(arg, 'f')
})
}
fn is_dangerous_git_subcommand(command: &[String]) -> bool {
if command.is_empty() {
return false;
}
let first_arg = command[0].as_str();
match first_arg {
"reset" | "rm" => true,
"branch" => git_branch_is_delete(&command[1..]),
"push" => git_push_is_dangerous(&command[1..]),
"clean" => git_clean_is_force(&command[1..]),
opt if opt.starts_with('-') => {
if let Some((idx, subcommand)) =
find_git_subcommand_from_args(command, &["reset", "rm", "branch", "push", "clean"])
{
match subcommand {
"reset" | "rm" => true,
"branch" => git_branch_is_delete(&command[idx + 1..]),
"push" => git_push_is_dangerous(&command[idx + 1..]),
"clean" => git_clean_is_force(&command[idx + 1..]),
_ => false,
}
} else {
false
}
}
_ => false,
}
}
fn find_git_subcommand_from_args<'a>(
args: &'a [String],
subcommands: &[&str],
) -> Option<(usize, &'a str)> {
let mut skip_next = false;
for (idx, arg) in args.iter().enumerate() {
if skip_next {
skip_next = false;
continue;
}
let arg = arg.as_str();
if is_git_global_option_with_inline_value(arg) {
continue;
}
if is_git_global_option_with_value(arg) {
skip_next = true;
continue;
}
if arg == "--" || arg.starts_with('-') {
continue;
}
if subcommands.contains(&arg) {
return Some((idx, arg));
}
return None;
}
None
}
fn is_dangerous_to_call_with_exec(command: &[String]) -> bool {
if command.is_empty() {
return false;
}
let cmd0 = command.first().map(String::as_str);
let base_cmd = extract_command_name(cmd0.unwrap_or(""));
match base_cmd {
"git" => {
let Some((subcommand_idx, subcommand)) =
find_git_subcommand(command, &["reset", "rm", "branch", "push", "clean"])
else {
return false;
};
match subcommand {
"reset" | "rm" => true,
"branch" => git_branch_is_delete(&command[subcommand_idx + 1..]),
"push" => git_push_is_dangerous(&command[subcommand_idx + 1..]),
"clean" => git_clean_is_force(&command[subcommand_idx + 1..]),
other => {
debug_assert!(false, "unexpected git subcommand from matcher: {other}");
false
}
}
}
"rm" => matches!(
command.get(1).map(String::as_str),
Some("-f" | "-rf" | "-fr" | "-r")
),
_ if base_cmd == "mkfs" || base_cmd.starts_with("mkfs.") => true,
"dd" | "shutdown" | "reboot" | "init" => true,
_ if base_cmd.ends_with(':') && command.len() >= 2 => command[1] == "(){:|:&};:",
"sudo" => {
if command.len() > 1 {
is_dangerous_to_call_with_exec(&command[1..])
} else {
false
}
}
_ => is_dangerous_git_subcommand(command),
}
}
fn extract_command_name(cmd: &str) -> &str {
std::path::Path::new(cmd)
.file_name()
.and_then(|osstr| osstr.to_str())
.unwrap_or(cmd)
}
#[cfg(test)]
mod tests {
use super::*;
fn vec_str(args: &[&str]) -> Vec<String> {
args.iter().map(|s| s.to_string()).collect()
}
#[test]
fn git_reset_is_dangerous() {
let cmd = vec!["git".to_string(), "reset".to_string()];
assert!(is_dangerous_to_call_with_exec(&cmd));
}
#[test]
fn git_reset_hard_is_dangerous() {
let cmd = vec!["git".to_string(), "reset".to_string(), "--hard".to_string()];
assert!(is_dangerous_to_call_with_exec(&cmd));
}
#[test]
fn git_status_is_safe() {
let cmd = vec!["git".to_string(), "status".to_string()];
assert!(!is_dangerous_to_call_with_exec(&cmd));
}
#[test]
fn git_log_is_safe() {
let cmd = vec!["git".to_string(), "log".to_string()];
assert!(!is_dangerous_to_call_with_exec(&cmd));
}
#[test]
fn rm_f_is_dangerous() {
let cmd = vec!["rm".to_string(), "-f".to_string(), "file.txt".to_string()];
assert!(is_dangerous_to_call_with_exec(&cmd));
}
#[test]
fn rm_rf_is_dangerous() {
let cmd = vec!["rm".to_string(), "-rf".to_string(), "/".to_string()];
assert!(is_dangerous_to_call_with_exec(&cmd));
}
#[test]
fn rm_without_flags_is_safe() {
let cmd = vec!["rm".to_string()];
assert!(!is_dangerous_to_call_with_exec(&cmd));
}
#[test]
fn mkfs_is_dangerous() {
let cmd = vec!["mkfs".to_string()];
assert!(is_dangerous_to_call_with_exec(&cmd));
}
#[test]
fn mkfs_variants_are_dangerous() {
let cmd = vec!["mkfs.ext4".to_string(), "/dev/sda1".to_string()];
assert!(is_dangerous_to_call_with_exec(&cmd));
}
#[test]
fn dd_is_dangerous() {
let cmd = vec!["dd".to_string(), "if=/dev/zero".to_string()];
assert!(is_dangerous_to_call_with_exec(&cmd));
}
#[test]
fn shutdown_is_dangerous() {
let cmd = vec!["shutdown".to_string()];
assert!(is_dangerous_to_call_with_exec(&cmd));
}
#[test]
fn sudo_git_reset_is_dangerous() {
let cmd = vec![
"sudo".to_string(),
"git".to_string(),
"reset".to_string(),
"--hard".to_string(),
];
assert!(is_dangerous_to_call_with_exec(&cmd));
}
#[test]
fn sudo_git_status_is_safe() {
let cmd = vec!["sudo".to_string(), "git".to_string(), "status".to_string()];
assert!(!is_dangerous_to_call_with_exec(&cmd));
}
#[test]
fn absolute_path_git_reset_is_dangerous() {
let cmd = vec!["/usr/bin/git".to_string(), "reset".to_string()];
assert!(is_dangerous_to_call_with_exec(&cmd));
}
#[test]
fn empty_command_is_safe() {
let cmd: Vec<String> = vec![];
assert!(!is_dangerous_to_call_with_exec(&cmd));
}
#[test]
fn command_might_be_dangerous_detects_git_reset() {
let cmd = vec!["git".to_string(), "reset".to_string()];
assert!(command_might_be_dangerous(&cmd));
}
#[test]
fn command_might_be_dangerous_allows_git_status() {
let cmd = vec!["git".to_string(), "status".to_string()];
assert!(!command_might_be_dangerous(&cmd));
}
#[test]
fn git_branch_delete_is_dangerous() {
assert!(command_might_be_dangerous(&vec_str(&[
"git", "branch", "-d", "feature",
])));
assert!(command_might_be_dangerous(&vec_str(&[
"git", "branch", "-D", "feature",
])));
let script = "git branch --delete feature";
if let Ok(sub_commands) = crate::command_safety::shell_parser::parse_shell_commands(script)
{
for sub_cmd in sub_commands {
assert!(
command_might_be_dangerous(&sub_cmd),
"sub-command should be dangerous: {:?}",
sub_cmd
);
}
}
}
#[test]
fn git_branch_delete_with_stacked_short_flags_is_dangerous() {
assert!(command_might_be_dangerous(&vec_str(&[
"git", "branch", "-dv", "feature",
])));
assert!(command_might_be_dangerous(&vec_str(&[
"git", "branch", "-vd", "feature",
])));
assert!(command_might_be_dangerous(&vec_str(&[
"git", "branch", "-vD", "feature",
])));
assert!(command_might_be_dangerous(&vec_str(&[
"git", "branch", "-Dvv", "feature",
])));
}
#[test]
fn git_branch_delete_with_global_options_is_dangerous() {
assert!(command_might_be_dangerous(&vec_str(&[
"git", "-C", ".", "branch", "-d", "feature",
])));
assert!(command_might_be_dangerous(&vec_str(&[
"git",
"-c",
"color.ui=false",
"branch",
"-D",
"feature",
])));
let script = "git -C . branch -d feature";
if let Ok(sub_commands) = crate::command_safety::shell_parser::parse_shell_commands(script)
{
for sub_cmd in sub_commands {
assert!(
command_might_be_dangerous(&sub_cmd),
"sub-command should be dangerous: {:?}",
sub_cmd
);
}
}
}
#[test]
fn git_checkout_reset_is_not_dangerous() {
assert!(!command_might_be_dangerous(&vec_str(&[
"git", "checkout", "reset",
])));
}
#[test]
fn git_push_force_is_dangerous() {
assert!(command_might_be_dangerous(&vec_str(&[
"git", "push", "--force", "origin", "main",
])));
assert!(command_might_be_dangerous(&vec_str(&[
"git", "push", "-f", "origin", "main",
])));
assert!(command_might_be_dangerous(&vec_str(&[
"git",
"-C",
".",
"push",
"--force-with-lease",
"origin",
"main",
])));
}
#[test]
fn git_push_plus_refspec_is_dangerous() {
assert!(command_might_be_dangerous(&vec_str(&[
"git", "push", "origin", "+main",
])));
assert!(command_might_be_dangerous(&vec_str(&[
"git",
"push",
"origin",
"+refs/heads/main:refs/heads/main",
])));
}
#[test]
fn git_push_delete_flag_is_dangerous() {
assert!(command_might_be_dangerous(&vec_str(&[
"git", "push", "--delete", "origin", "feature",
])));
assert!(command_might_be_dangerous(&vec_str(&[
"git", "push", "-d", "origin", "feature",
])));
}
#[test]
fn git_push_delete_refspec_is_dangerous() {
assert!(command_might_be_dangerous(&vec_str(&[
"git", "push", "origin", ":feature",
])));
let script = "git push origin :feature";
if let Ok(sub_commands) = crate::command_safety::shell_parser::parse_shell_commands(script)
{
for sub_cmd in sub_commands {
assert!(
command_might_be_dangerous(&sub_cmd),
"sub-command should be dangerous: {:?}",
sub_cmd
);
}
}
}
#[test]
fn git_push_without_force_is_not_dangerous() {
assert!(!command_might_be_dangerous(&vec_str(&[
"git", "push", "origin", "main",
])));
}
#[test]
fn git_clean_force_is_dangerous_even_when_f_is_not_first_flag() {
assert!(command_might_be_dangerous(&vec_str(&[
"git", "clean", "-fdx",
])));
assert!(command_might_be_dangerous(&vec_str(&[
"git", "clean", "-xdf",
])));
assert!(command_might_be_dangerous(&vec_str(&[
"git", "clean", "--force",
])));
}
}