#![cfg_attr(not(feature = "command-exec"), allow(dead_code))]
#[cfg(feature = "command-exec")]
pub(crate) mod broker;
mod helper;
pub(crate) mod procfs;
#[cfg(all(feature = "command-exec", any(test, feature = "testing")))]
pub mod testing;
#[cfg(all(test, feature = "command-exec"))]
mod tests_e2e;
pub use helper::run_helper_if_invoked;
pub(crate) const HELPER_NAME: &str = "synaps-sudo-askpass";
pub(crate) const SOCKET_NAME: &str = "sock";
pub(crate) const TOKEN_NAME: &str = "token";
pub(crate) const MAX_REPLY: usize = 4096;
pub(crate) const MAX_PROMPT: usize = 512;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct Refusal(pub(crate) String);
impl Refusal {
pub(crate) fn new(why: impl Into<String>) -> Self {
Self(why.into())
}
}
impl std::fmt::Display for Refusal {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
pub(crate) trait CallerPolicy: Send + Sync {
fn check_sudo(&self, pid: u32) -> Result<(), Refusal>;
fn check_helper(&self, pid: u32) -> Result<(), Refusal>;
fn check_ancestry(&self, pid: u32, root: u32) -> Result<(), Refusal> {
if procfs::descends_from(pid, root) {
Ok(())
} else {
Err(Refusal::new(
"sudo is not running inside this bash tool call",
))
}
}
}
pub(crate) struct SystemPolicy;
impl CallerPolicy for SystemPolicy {
fn check_sudo(&self, pid: u32) -> Result<(), Refusal> {
let status = procfs::status(pid)
.map_err(|e| Refusal::new(format!("cannot inspect the parent process: {e}")))?;
let exe = procfs::exe_metadata(pid);
judge_sudo(&status, unsafe { libc::getuid() }, exe)
}
fn check_helper(&self, pid: u32) -> Result<(), Refusal> {
let ours = procfs::exe_metadata(std::process::id())
.map_err(|e| Refusal::new(format!("cannot inspect this binary: {e}")))?;
let theirs = procfs::exe_metadata(pid)
.map_err(|e| Refusal::new(format!("cannot inspect the requester: {e}")))?;
use std::os::unix::fs::MetadataExt;
if (ours.dev(), ours.ino()) == (theirs.dev(), theirs.ino()) {
Ok(())
} else {
Err(Refusal::new(
"the requester is not the synaps askpass helper",
))
}
}
}
pub(crate) fn judge_sudo(
status: &procfs::ProcStatus,
my_uid: u32,
exe: std::io::Result<std::fs::Metadata>,
) -> Result<(), Refusal> {
if status.name != "sudo" {
return Err(Refusal::new("the parent process is not sudo"));
}
let [real, effective, saved, _fs] = status.uids;
if real != my_uid {
return Err(Refusal::new("sudo was started by another user"));
}
if effective != 0 || saved != 0 {
return Err(Refusal::new("the parent sudo is not running set-uid root"));
}
match exe {
Ok(meta) => {
use std::os::unix::fs::{MetadataExt, PermissionsExt};
let mode = meta.permissions().mode();
if !meta.is_file() || meta.uid() != 0 || mode & 0o4000 == 0 || mode & 0o022 != 0 {
return Err(Refusal::new(
"the parent sudo is not a root-owned set-uid binary",
));
}
Ok(())
}
Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => Ok(()),
Err(e) => Err(Refusal::new(format!("cannot inspect the parent sudo: {e}"))),
}
}
pub(crate) fn sanitize_line(text: &str, max: usize) -> String {
let mut out = String::new();
let mut count = 0;
for ch in text.chars() {
let piece = if is_unsafe_char(ch) {
ch.escape_unicode().to_string()
} else {
ch.to_string()
};
let len = piece.chars().count();
if count + len > max {
out.push('…');
return out;
}
out.push_str(&piece);
count += len;
}
out
}
fn is_unsafe_char(ch: char) -> bool {
ch.is_control()
|| matches!(ch,
'\u{200B}'..='\u{200F}'
| '\u{2028}'..='\u{202E}'
| '\u{2060}'..='\u{2069}'
| '\u{FEFF}'
| '\u{FFF9}'..='\u{FFFB}')
}
pub(crate) fn display_command(argv: &[String], max: usize) -> String {
let mut words = vec!["sudo".to_string()];
words.extend(argv.iter().skip(1).map(|arg| shell_quote(arg)));
sanitize_line(&words.join(" "), max)
}
fn shell_quote(arg: &str) -> String {
let plain = !arg.is_empty()
&& arg
.chars()
.all(|c| c.is_ascii_alphanumeric() || "_@%+=:,./-".contains(c));
if plain {
arg.to_string()
} else {
format!("'{}'", arg.replace('\'', r"'\''"))
}
}
pub(crate) const PROMPT_TITLE: &str = "sudo password required";
pub(crate) fn prompt_text(sudo_argv: &[String], sudo_prompt: &str) -> String {
let command = display_command(sudo_argv, 240);
let asked = sanitize_line(sudo_prompt.trim(), 120);
if asked.is_empty() {
format!("sudo is asking for your password to run:\n{command}")
} else {
format!("sudo is asking for your password to run:\n{command}\n{asked}")
}
}
#[cfg(test)]
mod tests {
use super::*;
fn status(name: &str, uids: [u32; 4]) -> procfs::ProcStatus {
procfs::ProcStatus {
name: name.into(),
ppid: 1,
uids,
}
}
fn denied() -> std::io::Result<std::fs::Metadata> {
Err(std::io::Error::from(std::io::ErrorKind::PermissionDenied))
}
#[test]
fn a_setuid_root_sudo_started_by_us_is_accepted() {
assert_eq!(
judge_sudo(&status("sudo", [1000, 0, 0, 0]), 1000, denied()),
Ok(())
);
}
#[test]
fn anything_else_is_refused() {
let cases = [
(status("bash", [1000, 0, 0, 0]), "not sudo"),
(
status("sudo", [1000, 1000, 1000, 1000]),
"not running set-uid root",
),
(
status("sudo", [1000, 0, 1000, 0]),
"not running set-uid root",
),
(status("sudo", [1001, 0, 0, 0]), "another user"),
];
for (st, why) in cases {
let err = judge_sudo(&st, 1000, denied()).unwrap_err();
assert!(err.0.contains(why), "{st:?}: {err}");
}
let gone = Err(std::io::Error::from(std::io::ErrorKind::NotFound));
assert!(judge_sudo(&status("sudo", [1000, 0, 0, 0]), 1000, gone).is_err());
}
#[test]
fn a_readable_exe_must_be_a_root_owned_setuid_file() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("sudo");
std::fs::write(&file, b"x").unwrap();
let meta = std::fs::metadata(&file);
let err = judge_sudo(&status("sudo", [1000, 0, 0, 0]), 1000, meta).unwrap_err();
assert!(err.0.contains("root-owned set-uid"), "{err}");
}
#[test]
fn the_system_policy_refuses_impostors() {
let me = std::process::id();
assert!(SystemPolicy
.check_sudo(me)
.unwrap_err()
.0
.contains("not sudo"));
let dir = tempfile::tempdir().unwrap();
let fake = dir.path().join("sudo");
std::os::unix::fs::symlink("/bin/sleep", &fake).unwrap();
let mut child = std::process::Command::new(&fake).arg("30").spawn().unwrap();
let pid = child.id();
procfs::tests::wait_for_name(pid, "sudo");
let verdict = SystemPolicy.check_sudo(pid);
let _ = child.kill();
let _ = child.wait();
assert!(verdict.unwrap_err().0.contains("not running set-uid root"));
}
#[test]
fn the_system_policy_knows_its_own_binary() {
assert_eq!(SystemPolicy.check_helper(std::process::id()), Ok(()));
let mut child = std::process::Command::new("sleep")
.arg("30")
.spawn()
.unwrap();
procfs::tests::wait_for_name(child.id(), "sleep");
let verdict = SystemPolicy.check_helper(child.id());
let _ = child.kill();
let _ = child.wait();
assert!(verdict
.unwrap_err()
.0
.contains("not the synaps askpass helper"));
}
#[test]
fn prompt_text_names_the_command_and_is_bounded() {
let argv: Vec<String> = ["sudo", "-A", "apt", "install", "it's here"]
.iter()
.map(|s| s.to_string())
.collect();
let text = prompt_text(&argv, "[sudo] password for me: ");
assert_eq!(
text,
"sudo is asking for your password to run:\n\
sudo -A apt install 'it'\\''s here'\n[sudo] password for me:"
);
let long: Vec<String> = std::iter::once("sudo".to_string())
.chain(std::iter::repeat_n("x".repeat(50), 40))
.collect();
let text = prompt_text(&long, &"p".repeat(1000));
let lines: Vec<&str> = text.lines().collect();
assert_eq!(lines.len(), 3);
assert_eq!(lines[1].chars().count(), 241, "240 + ellipsis");
assert!(lines[1].ends_with('…'));
assert_eq!(lines[2].chars().count(), 121);
}
#[test]
fn prompt_text_escapes_control_and_invisible_characters() {
let argv: Vec<String> = ["sudo", "rm\n-rf /\x1b[2J", "a\u{202e}b", "z\u{200b}w"]
.iter()
.map(|s| s.to_string())
.collect();
let text = prompt_text(&argv, "pw\r\n:");
let lines: Vec<&str> = text.lines().collect();
assert_eq!(lines.len(), 3, "{text:?}");
assert!(lines[1].contains(r"\u{a}"), "{text:?}");
assert!(lines[1].contains(r"\u{1b}"), "{text:?}");
assert!(lines[1].contains(r"\u{202e}"), "{text:?}");
assert!(lines[1].contains(r"\u{200b}"), "{text:?}");
assert!(!text.contains('\u{1b}') && !text.contains('\u{202e}') && !text.contains('\r'));
assert_eq!(lines[2], r"pw\u{d}\u{a}:");
}
}