High severity8.4NVD Advisory· Published Mar 2, 2026· Updated Apr 15, 2026
CVE-2026-21882
CVE-2026-21882
Description
theshit is a command-line utility that automatically detects and fixes common mistakes in shell commands. Prior to version 0.2.0, improper privilege dropping allows local privilege escalation via command re-execution. This issue has been patched in version 0.2.0.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
theshitcrates.io | < 0.2.0 | 0.2.0 |
Patches
15293957b119eMerge commit from fork
2 files changed · +322 −132
src/fix/output.rs+288 −0 added@@ -0,0 +1,288 @@ +use crate::error::{AppError, AppResult}; +use crate::fix::structs::CommandOutput; +use crate::misc::split_command; +use crossterm::style::Stylize; +use libc::{geteuid, getuid, gid_t, uid_t}; +use std::io; +use std::os::unix::process::CommandExt; +use std::process::Command; +use std::time::Duration; + +pub fn get_command_output(expand_command: String) -> AppResult<CommandOutput> { + let split_command = split_command(&expand_command); + + if split_command.is_empty() { + return Err(AppError::Other("Command is empty".to_string())); + } + + let timeout = get_command_timeout(&split_command[0]); + + let mut command = Command::new(&split_command[0]); + command + .args(&split_command[1..]) + .env("LANG", "C") // Set locale to C to avoid issues with rules that depend on locale + .env("LC_ALL", "C"); + + let permission_issue = PermissionIssue::detect(); + permission_issue.fix(&mut command)?; + + let (sender, receiver) = std::sync::mpsc::channel(); + + let _handle = std::thread::spawn(move || { + let output = command.output(); + let _ = sender.send(output); + }); + + match receiver.recv_timeout(timeout) { + Ok(Ok(output)) => Ok(CommandOutput::from(output)), + Ok(Err(e)) => Err(AppError::Io(e)), + Err(_) => Err(AppError::Other("Command execution timed out".to_string())), + } +} + +enum PermissionIssue { + EuidNotEqualRuid, // SUID/SGID execution detected + SudoEnvironment, // SUDO_UID or SUDO_GID is set + DoasEnvironment, // Detected running in a Doas environment + Normal, // No permission issues detected +} + +impl PermissionIssue { + fn detect() -> Self { + if unsafe { geteuid() != getuid() } { + return PermissionIssue::EuidNotEqualRuid; + } + if std::env::var("SUDO_UID").is_ok() || std::env::var("SUDO_GID").is_ok() { + return PermissionIssue::SudoEnvironment; + } + if std::env::var("DOAS_USER").is_ok() { + return PermissionIssue::DoasEnvironment; + } + PermissionIssue::Normal + } + + fn fix(self, command: &mut Command) -> AppResult<()> { + let current_euid = unsafe { geteuid() }; + let current_uid = unsafe { getuid() }; + let current_egid = unsafe { libc::getegid() }; + let current_gid = unsafe { libc::getgid() }; + let change_supplementary_groups = matches!( + self, + PermissionIssue::SudoEnvironment | PermissionIssue::DoasEnvironment + ); + + let target = match self { + PermissionIssue::EuidNotEqualRuid => (current_uid, current_gid), + PermissionIssue::SudoEnvironment => { + let uid = std::env::var("SUDO_UID").map_err(|e| { + AppError::Security(format!( + "{} Detected sudo environment, but cannot get SUDO_UID: {}", + "SECURITY ERROR:".red().bold(), + e + )) + })?; + let gid = std::env::var("SUDO_GID").map_err(|e| { + AppError::Security(format!( + "{} Detected sudo environment, but cannot get SUDO_GID: {}", + "SECURITY ERROR:".red().bold(), + e + )) + })?; + ( + uid.parse::<uid_t>().map_err(|e| { + AppError::Security(format!( + "{} Invalid SUDO_UID value: {}", + "SECURITY ERROR:".red().bold(), + e + )) + })?, + gid.parse::<gid_t>().map_err(|e| { + AppError::Security(format!( + "{} Invalid SUDO_GID value: {}", + "SECURITY ERROR:".red().bold(), + e + )) + })?, + ) + } + PermissionIssue::DoasEnvironment => { + let doas_user = std::env::var("DOAS_USER").map_err(|e| { + AppError::Security(format!( + "{} Detected doas environment, but cannot get DOAS_USER: {}", + "SECURITY ERROR:".red().bold(), + e + )) + })?; + get_ids_by_username(doas_user)? + } + PermissionIssue::Normal => return Ok(()), + }; + + if target == (current_euid, current_egid) { + return Ok(()); + } + + let user_context = get_user_context_by_uid(target.0)?; + + if change_supplementary_groups && current_euid != 0 { + return Err(AppError::Security(format!( + "{} Cannot change supplementary groups when not running as root.", + "SECURITY ERROR:".red().bold() + ))); + } + + command.env("USER", &user_context.username); + command.env("HOME", &user_context.home_dir); + + command.env_remove("SUDO_UID"); + command.env_remove("SUDO_GID"); + command.env_remove("SUDO_USER"); + command.env_remove("SUDO_COMMAND"); + command.env_remove("DOAS_USER"); + + let username_cstring = std::ffi::CString::new(user_context.username).unwrap(); + + unsafe { + command.pre_exec(move || { + if current_euid == 0 + && change_supplementary_groups + && libc::initgroups(username_cstring.as_ptr(), target.1) != 0 + { + return Err(io::Error::last_os_error()); + } + if libc::setgid(target.1) != 0 { + return Err(io::Error::last_os_error()); + } + if libc::setuid(target.0) != 0 { + return Err(io::Error::last_os_error()); + } + Ok(()) + }); + } + Ok(()) + } +} +fn get_ids_by_username(username: String) -> AppResult<(uid_t, gid_t)> { + let user_cstr = std::ffi::CString::new(username.clone()).map_err(|e| { + AppError::Security(format!( + "{} Username contains null bytes: {}", + "SECURITY ERROR:".red().bold(), + e + )) + })?; + let passwd = unsafe { libc::getpwnam(user_cstr.as_ptr()) }; + if passwd.is_null() { + return Err(AppError::Security(format!( + "{} Cannot find user info for '{}'", + "SECURITY ERROR:".red().bold(), + username + ))); + } + unsafe { Ok(((*passwd).pw_uid, (*passwd).pw_gid)) } +} + +struct UserContext { + username: String, + home_dir: String, +} + +fn get_user_context_by_uid(uid: uid_t) -> AppResult<UserContext> { + let passwd = unsafe { libc::getpwuid(uid) }; + if passwd.is_null() { + return Err(AppError::Security(format!( + "{} Cannot find user info for UID '{}'", + "SECURITY ERROR:".red().bold(), + uid + ))); + } + unsafe { + let username = std::ffi::CStr::from_ptr((*passwd).pw_name) + .to_string_lossy() + .into_owned(); + + let home_dir = std::ffi::CStr::from_ptr((*passwd).pw_dir) + .to_string_lossy() + .into_owned(); + + Ok(UserContext { username, home_dir }) + } +} + +fn get_command_timeout(command_name: &str) -> Duration { + // Get the base command name without path + let base_command = command_name.split('/').next_back().unwrap_or(command_name); + + match base_command { + // Slow commands that may take longer + "gradle" | "gradlew" => Duration::from_secs(10), + "mvn" | "maven" => Duration::from_secs(10), + "npm" | "yarn" | "pnpm" => Duration::from_secs(10), + "cargo" => Duration::from_secs(10), + "docker" | "podman" => Duration::from_secs(10), + "kubectl" | "helm" => Duration::from_secs(10), + "terraform" | "tf" => Duration::from_secs(10), + "ansible" | "ansible-playbook" => Duration::from_secs(10), + + // Medium-speed commands + "git" => Duration::from_secs(5), + "make" => Duration::from_secs(5), + "pip" | "pip3" => Duration::from_secs(5), + "composer" => Duration::from_secs(5), + "bundle" => Duration::from_secs(5), + + // Fast commands - default timeout + _ => Duration::from_secs(1), + } +} +#[cfg(test)] +mod tests { + use super::*; + use anyhow::__private::kind::TraitKind; + use std::io::ErrorKind; + + #[test] + fn test_get_command_timeout_fast_commands() { + assert_eq!(get_command_timeout("ls"), Duration::from_secs(1)); + assert_eq!(get_command_timeout("echo"), Duration::from_secs(1)); + assert_eq!(get_command_timeout("cat"), Duration::from_secs(1)); + assert_eq!(get_command_timeout("/bin/ls"), Duration::from_secs(1)); + } + + #[test] + fn test_get_command_timeout_slow_commands() { + assert_eq!(get_command_timeout("gradle"), Duration::from_secs(10)); + assert_eq!(get_command_timeout("gradlew"), Duration::from_secs(10)); + assert_eq!(get_command_timeout("mvn"), Duration::from_secs(10)); + assert_eq!(get_command_timeout("npm"), Duration::from_secs(10)); + assert_eq!(get_command_timeout("cargo"), Duration::from_secs(10)); + assert_eq!(get_command_timeout("docker"), Duration::from_secs(10)); + assert_eq!( + get_command_timeout("/usr/local/bin/gradle"), + Duration::from_secs(10) + ); + } + + #[test] + fn test_get_command_timeout_medium_commands() { + assert_eq!(get_command_timeout("git"), Duration::from_secs(5)); + assert_eq!(get_command_timeout("make"), Duration::from_secs(5)); + assert_eq!(get_command_timeout("pip"), Duration::from_secs(5)); + assert_eq!(get_command_timeout("/usr/bin/git"), Duration::from_secs(5)); + } + + #[test] + fn test_get_command_output_empty_command() { + let result = get_command_output("".to_string()); + assert!(result.is_err()); + let err = result.err().expect("Expected error but got success"); + assert!(err.to_string().contains("Command is empty")); + } + + #[test] + fn test_get_command_output_nonexistent_command() { + let result = get_command_output("nonexistent_command_12345".to_string()); + assert!(result.is_err()); + let err = result.err().expect("Expected error but got success"); + assert!(err.to_string().contains("No such file or directory")); + } +}
src/fix.rs+34 −132 modified@@ -1,35 +1,54 @@ +mod output; mod python; mod rust; mod structs; +use crate::error::AppError; use crate::fix::rust::NativeRule; use crate::fix::structs::CommandOutput; use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers, read}; use crossterm::style::Stylize; use std::io::{ErrorKind, Write}; use std::path::PathBuf; -use std::process::Command; use std::str::FromStr; -use std::sync::mpsc; -use std::time::Duration; -use std::{fs, io, thread}; +use std::{fs, io}; use structs::RawModeGuard; pub fn fix_command(command: String, expand_command: String) -> io::Result<String> { - let command_output = match get_command_output(expand_command) { + let command_output = match output::get_command_output(expand_command) { Ok(output) => output, - Err(e) => match e.kind() { - ErrorKind::NotFound => CommandOutput::new( - "command not found".to_string(), - "command not found".to_string(), - ), - ErrorKind::PermissionDenied => CommandOutput::new( - "permission denied".to_string(), - "permission denied".to_string(), - ), + // Err(e) => match e.kind() { + // ErrorKind::NotFound => CommandOutput::new( + // "command not found".to_string(), + // "command not found".to_string(), + // ), + // ErrorKind::PermissionDenied => CommandOutput::new( + // "permission denied".to_string(), + // "permission denied".to_string(), + // ), + // _ => { + // eprintln!("{}: {}", "Error executing command".red(), e); + // return Err(e); + // } + // }, + Err(e) => match e { + AppError::Io(e) => match e.kind() { + ErrorKind::NotFound => CommandOutput::new( + "command not found".to_string(), + "command not found".to_string(), + ), + ErrorKind::PermissionDenied => CommandOutput::new( + "permission denied".to_string(), + "permission denied".to_string(), + ), + _ => { + eprintln!("{}: {}", "Error executing command".red(), e); + return Err(e); + } + }, _ => { eprintln!("{}: {}", "Error executing command".red(), e); - return Err(e); + return Err(io::Error::other("Error executing command")); } }, }; @@ -111,72 +130,6 @@ pub fn fix_command(command: String, expand_command: String) -> io::Result<String Ok(choose_fixed_command(fixed_commands)) } -fn get_command_timeout(command_name: &str) -> Duration { - // Get the base command name without path - let base_command = command_name.split('/').next_back().unwrap_or(command_name); - - match base_command { - // Slow commands that may take longer - "gradle" | "gradlew" => Duration::from_secs(10), - "mvn" | "maven" => Duration::from_secs(10), - "npm" | "yarn" | "pnpm" => Duration::from_secs(10), - "cargo" => Duration::from_secs(10), - "docker" | "podman" => Duration::from_secs(10), - "kubectl" | "helm" => Duration::from_secs(10), - "terraform" | "tf" => Duration::from_secs(10), - "ansible" | "ansible-playbook" => Duration::from_secs(10), - - // Medium-speed commands - "git" => Duration::from_secs(5), - "make" => Duration::from_secs(5), - "pip" | "pip3" => Duration::from_secs(5), - "composer" => Duration::from_secs(5), - "bundle" => Duration::from_secs(5), - - // Fast commands - default timeout - _ => Duration::from_secs(1), - } -} - -fn get_command_output(expand_command: String) -> io::Result<CommandOutput> { - let split_command = shell_words::split(&expand_command) - .map_err(|e| io::Error::other(format!("Failed to parse command: {e}")))?; - - if split_command.is_empty() { - return Err(io::Error::new( - ErrorKind::InvalidInput, - "Empty command provided", - )); - } - - let timeout = get_command_timeout(&split_command[0]); - - let child = Command::new(&split_command[0]) - .args(&split_command[1..]) - .env("LANG", "C") - .env("LC_ALL", "C") - .spawn()?; - - let (sender, receiver) = mpsc::channel(); - - let _handle = thread::spawn(move || { - let result = child.wait_with_output(); - let _ = sender.send(result); - }); - - match receiver.recv_timeout(timeout) { - Ok(Ok(output)) => Ok(CommandOutput::from(output)), - Ok(Err(e)) => Err(e), - Err(mpsc::RecvTimeoutError::Timeout) => Err(io::Error::new( - ErrorKind::TimedOut, - format!("Command timed out after {:?}", timeout), - )), - Err(mpsc::RecvTimeoutError::Disconnected) => { - Err(io::Error::other("Command thread disconnected unexpectedly")) - } - } -} - fn choose_fixed_command(mut fixed_commands: Vec<String>) -> String { if fixed_commands.is_empty() { eprintln!( @@ -292,54 +245,3 @@ fn choose_fixed_command(mut fixed_commands: Vec<String>) -> String { } } } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_get_command_timeout_fast_commands() { - assert_eq!(get_command_timeout("ls"), Duration::from_secs(1)); - assert_eq!(get_command_timeout("echo"), Duration::from_secs(1)); - assert_eq!(get_command_timeout("cat"), Duration::from_secs(1)); - assert_eq!(get_command_timeout("/bin/ls"), Duration::from_secs(1)); - } - - #[test] - fn test_get_command_timeout_slow_commands() { - assert_eq!(get_command_timeout("gradle"), Duration::from_secs(10)); - assert_eq!(get_command_timeout("gradlew"), Duration::from_secs(10)); - assert_eq!(get_command_timeout("mvn"), Duration::from_secs(10)); - assert_eq!(get_command_timeout("npm"), Duration::from_secs(10)); - assert_eq!(get_command_timeout("cargo"), Duration::from_secs(10)); - assert_eq!(get_command_timeout("docker"), Duration::from_secs(10)); - assert_eq!( - get_command_timeout("/usr/local/bin/gradle"), - Duration::from_secs(10) - ); - } - - #[test] - fn test_get_command_timeout_medium_commands() { - assert_eq!(get_command_timeout("git"), Duration::from_secs(5)); - assert_eq!(get_command_timeout("make"), Duration::from_secs(5)); - assert_eq!(get_command_timeout("pip"), Duration::from_secs(5)); - assert_eq!(get_command_timeout("/usr/bin/git"), Duration::from_secs(5)); - } - - #[test] - fn test_get_command_output_empty_command() { - let result = get_command_output("".to_string()); - assert!(result.is_err()); - let err = result.err().expect("Expected error but got success"); - assert_eq!(err.kind(), ErrorKind::InvalidInput); - } - - #[test] - fn test_get_command_output_nonexistent_command() { - let result = get_command_output("nonexistent_command_12345".to_string()); - assert!(result.is_err()); - let err = result.err().expect("Expected error but got success"); - assert!(matches!(err.kind(), ErrorKind::NotFound)); - } -}
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
4News mentions
0No linked articles in our index yet.