sudo_rs/visudo/
mod.rs

1#![forbid(unsafe_code)]
2
3mod cli;
4mod help;
5
6use std::{
7    env, ffi,
8    fs::{File, Permissions},
9    io::{self, Read, Seek, Write},
10    os::unix::prelude::{MetadataExt, PermissionsExt},
11    path::{Path, PathBuf},
12    process::Command,
13    str,
14};
15
16use crate::{
17    common::resolve::CurrentUser,
18    sudo::{candidate_sudoers_file, diagnostic},
19    sudoers::{self, Sudoers},
20    system::{
21        file::{create_temporary_dir, Chown, FileLock},
22        interface::{GroupId, UserId},
23        signal::{consts::*, register_handlers, SignalStream},
24        Hostname, User,
25    },
26};
27
28use self::cli::{VisudoAction, VisudoOptions};
29use self::help::{long_help_message, USAGE_MSG};
30
31const VERSION: &str = env!("CARGO_PKG_VERSION");
32
33macro_rules! io_msg {
34    ($err:expr, $($tt:tt)*) => {
35        io::Error::new($err.kind(), format!("{}: {}", format_args!($($tt)*), $err))
36    };
37}
38
39pub fn main() {
40    if User::effective_uid() != User::real_uid() || User::effective_gid() != User::real_gid() {
41        println_ignore_io_error!(
42            "Visudo must not be installed as setuid binary.\n\
43             Please notify your packager about this misconfiguration.\n\
44             To prevent privilege escalation visudo will now abort.
45             "
46        );
47        std::process::exit(1);
48    }
49
50    let options = match VisudoOptions::from_env() {
51        Ok(options) => options,
52        Err(error) => {
53            println_ignore_io_error!("visudo: {error}\n{USAGE_MSG}");
54            std::process::exit(1);
55        }
56    };
57
58    let cmd = match options.action {
59        VisudoAction::Help => {
60            println_ignore_io_error!("{}", long_help_message());
61            std::process::exit(0);
62        }
63        VisudoAction::Version => {
64            println_ignore_io_error!("visudo version {VERSION}");
65            std::process::exit(0);
66        }
67        VisudoAction::Check => check,
68        VisudoAction::Run => run,
69    };
70
71    match cmd(options.file.as_deref(), options.perms, options.owner) {
72        Ok(()) => {}
73        Err(error) => {
74            eprintln_ignore_io_error!("visudo: {error}");
75            std::process::exit(1);
76        }
77    }
78}
79
80fn check(file_arg: Option<&str>, perms: bool, owner: bool) -> io::Result<()> {
81    let sudoers_path = &file_arg
82        .map(PathBuf::from)
83        .unwrap_or_else(candidate_sudoers_file);
84
85    let sudoers_file = File::open(sudoers_path)
86        .map_err(|err| io_msg!(err, "unable to open {}", sudoers_path.display()))?;
87
88    let metadata = sudoers_file.metadata()?;
89
90    if file_arg.is_none() || perms {
91        // For some reason, the MSB of the mode is on so we need to mask it.
92        let mode = metadata.permissions().mode() & 0o777;
93
94        if mode != 0o440 {
95            return Err(io::Error::new(
96                io::ErrorKind::Other,
97                format!(
98                    "{}: bad permissions, should be mode 0440, but found {mode:04o}",
99                    sudoers_path.display()
100                ),
101            ));
102        }
103    }
104
105    if file_arg.is_none() || owner {
106        let owner = (metadata.uid(), metadata.gid());
107
108        if owner != (0, 0) {
109            return Err(io::Error::new(
110                io::ErrorKind::Other,
111                format!(
112                    "{}: wrong owner (uid, gid) should be (0, 0), but found {owner:?}",
113                    sudoers_path.display()
114                ),
115            ));
116        }
117    }
118
119    let (_sudoers, errors) = Sudoers::read(&sudoers_file, sudoers_path)?;
120
121    if errors.is_empty() {
122        writeln!(io::stdout(), "{}: parsed OK", sudoers_path.display())?;
123        return Ok(());
124    }
125
126    for crate::sudoers::Error {
127        message,
128        source,
129        location,
130    } in errors
131    {
132        let path = source.as_deref().unwrap_or(sudoers_path);
133        diagnostic::diagnostic!("syntax error: {message}", path @ location);
134    }
135
136    Err(io::Error::new(io::ErrorKind::Other, "invalid sudoers file"))
137}
138
139fn run(file_arg: Option<&str>, perms: bool, owner: bool) -> io::Result<()> {
140    let sudoers_path = &file_arg
141        .map(PathBuf::from)
142        .unwrap_or_else(candidate_sudoers_file);
143
144    let (sudoers_file, existed) = if sudoers_path.exists() {
145        let file = File::options()
146            .read(true)
147            .write(true)
148            .open(sudoers_path)
149            .map_err(|e| {
150                io::Error::new(
151                    e.kind(),
152                    format!("Failed to open existing sudoers file at {sudoers_path:?}: {e}"),
153                )
154            })?;
155
156        (file, true)
157    } else {
158        // Create a sudoers file if it doesn't exist.
159        let file = File::create(sudoers_path).map_err(|e| {
160            io::Error::new(
161                e.kind(),
162                format!("Failed to create sudoers file at {sudoers_path:?}: {e}"),
163            )
164        })?;
165        // ogvisudo sets the permissions of the file so it can be read and written by the user and
166        // read by the group if the `-f` argument was passed.
167        if file_arg.is_some() {
168            file.set_permissions(Permissions::from_mode(0o640))
169                .map_err(|e| {
170                    io::Error::new(
171                        e.kind(),
172                        format!(
173                            "Failed to set permissions on new sudoers file at {sudoers_path:?}: {e}"
174                        ),
175                    )
176                })?;
177        }
178        (file, false)
179    };
180
181    let lock = FileLock::exclusive(&sudoers_file, true).map_err(|err| {
182        if err.kind() == io::ErrorKind::WouldBlock {
183            io_msg!(err, "{} busy, try again later", sudoers_path.display())
184        } else {
185            err
186        }
187    })?;
188
189    if perms || file_arg.is_none() {
190        sudoers_file.set_permissions(Permissions::from_mode(0o440))?;
191    }
192
193    if owner || file_arg.is_none() {
194        sudoers_file.chown(UserId::ROOT, GroupId::new(0))?;
195    }
196
197    let signal_stream = SignalStream::init()?;
198
199    let handlers = register_handlers([SIGTERM, SIGHUP, SIGINT, SIGQUIT])?;
200
201    let tmp_dir = create_temporary_dir()?;
202    let tmp_path = tmp_dir.join("sudoers");
203
204    {
205        let tmp_dir = tmp_dir.clone();
206        std::thread::spawn(|| -> io::Result<()> {
207            signal_stream.recv()?;
208
209            let _ = std::fs::remove_dir_all(tmp_dir);
210
211            drop(handlers);
212
213            std::process::exit(1)
214        });
215    }
216
217    let tmp_file = File::options()
218        .read(true)
219        .write(true)
220        .create(true)
221        .truncate(true)
222        .open(&tmp_path)?;
223
224    tmp_file.set_permissions(Permissions::from_mode(0o600))?;
225
226    let result = edit_sudoers_file(
227        existed,
228        sudoers_file,
229        sudoers_path,
230        lock,
231        tmp_file,
232        &tmp_path,
233    );
234
235    std::fs::remove_dir_all(tmp_dir)?;
236
237    result
238}
239
240fn edit_sudoers_file(
241    existed: bool,
242    mut sudoers_file: File,
243    sudoers_path: &Path,
244    lock: FileLock,
245    mut tmp_file: File,
246    tmp_path: &Path,
247) -> io::Result<()> {
248    let mut stderr = io::stderr();
249
250    let mut sudoers_contents = Vec::new();
251
252    // Since visudo is meant to run as root, resolve shouldn't fail
253    let current_user: User = match CurrentUser::resolve() {
254        Ok(user) => user.into(),
255        Err(err) => {
256            writeln!(stderr, "visudo: cannot resolve : {err}")?;
257            return Ok(());
258        }
259    };
260
261    let host_name = Hostname::resolve();
262
263    let editor_path = if existed {
264        // If the sudoers file existed, read its contents and write them into the temporary file.
265        sudoers_file.read_to_end(&mut sudoers_contents)?;
266        // Rewind the sudoers file so it can be written later.
267        sudoers_file.rewind()?;
268        // Write to the temporary file.
269        tmp_file.write_all(&sudoers_contents)?;
270
271        let (sudoers, _errors) = Sudoers::read(sudoers_contents.as_slice(), sudoers_path)?;
272
273        sudoers.visudo_editor_path(&host_name, &current_user, &current_user)
274    } else {
275        // there is no /etc/sudoers config yet, so use a system default
276        PathBuf::from(crate::defaults::SYSTEM_EDITOR)
277    };
278
279    loop {
280        Command::new(&editor_path)
281            .arg("--")
282            .arg(tmp_path)
283            .spawn()
284            .map_err(|_| {
285                io::Error::new(
286                    io::ErrorKind::NotFound,
287                    format!(
288                        "specified editor ({}) could not be used",
289                        editor_path.display()
290                    ),
291                )
292            })?
293            .wait_with_output()?;
294
295        let (sudoers, errors) = File::open(tmp_path)
296            .and_then(|reader| Sudoers::read(reader, tmp_path))
297            .map_err(|err| {
298                io_msg!(
299                    err,
300                    "unable to re-open temporary file ({}), {} unchanged",
301                    tmp_path.display(),
302                    sudoers_path.display()
303                )
304            })?;
305
306        if !errors.is_empty() {
307            writeln!(stderr, "The provided sudoers file format is not recognized or contains syntax errors. Please review:\n")?;
308
309            for crate::sudoers::Error {
310                message,
311                source,
312                location,
313            } in errors
314            {
315                let path = source.as_deref().unwrap_or(sudoers_path);
316                diagnostic::diagnostic!("syntax error: {message}", path @ location);
317            }
318
319            writeln!(stderr)?;
320
321            match ask_response(b"What now? e(x)it without saving / (e)dit again: ", b"xe")? {
322                b'x' => return Ok(()),
323                _ => continue,
324            }
325        } else {
326            if sudo_visudo_is_allowed(sudoers, &host_name) == Some(false) {
327                writeln!(
328                    stderr,
329                    "It looks like you have removed your ability to run 'sudo visudo' again.\n"
330                )?;
331                match ask_response(
332                    b"What now? e(x)it without saving / (e)dit again / lock me out and (S)ave: ",
333                    b"xeS",
334                )? {
335                    b'x' => return Ok(()),
336                    b'S' => {}
337                    _ => continue,
338                }
339            }
340
341            break;
342        }
343    }
344
345    let tmp_contents = std::fs::read(tmp_path)?;
346    // Only write to the sudoers file if the contents changed.
347    if tmp_contents == sudoers_contents {
348        writeln!(stderr, "visudo: {} unchanged", tmp_path.display())?;
349    } else {
350        sudoers_file.write_all(&tmp_contents)?;
351        let new_size = sudoers_file.stream_position()?;
352        sudoers_file.set_len(new_size)?;
353    }
354
355    lock.unlock()?;
356
357    Ok(())
358}
359
360// To detect potential lock-outs if the user called "sudo visudo".
361// Note that SUDO_USER will normally be set by sudo.
362//
363// This returns Some(false) if visudo is forbidden under the given config;
364// Some(true) if it is allowed; and None if it cannot be determined, which
365// will be the case if e.g. visudo was simply run as root.
366fn sudo_visudo_is_allowed(mut sudoers: Sudoers, host_name: &Hostname) -> Option<bool> {
367    let sudo_user =
368        User::from_name(&ffi::CString::new(env::var("SUDO_USER").ok()?).ok()?).ok()??;
369
370    let super_user = User::from_uid(UserId::ROOT).ok()??;
371
372    let request = sudoers::Request {
373        user: &super_user,
374        group: &super_user.primary_group().ok()?,
375        command: &env::current_exe().ok()?,
376        arguments: &[],
377    };
378
379    Some(matches!(
380        sudoers
381            .check(&sudo_user, host_name, request)
382            .authorization(),
383        sudoers::Authorization::Allowed { .. }
384    ))
385}
386
387// Make sure that the first valid response is the "safest" choice
388pub(crate) fn ask_response(prompt: &[u8], valid_responses: &[u8]) -> io::Result<u8> {
389    let stdin = io::stdin();
390    let stdout = io::stdout();
391    let mut stderr = io::stderr();
392
393    let mut stdin_handle = stdin.lock();
394    let mut stdout_handle = stdout.lock();
395
396    loop {
397        stdout_handle.write_all(prompt)?;
398        stdout_handle.flush()?;
399
400        let mut input = [0u8];
401        if let Err(err) = stdin_handle.read_exact(&mut input) {
402            writeln!(stderr, "visudo: cannot read user input: {err}")?;
403            return Ok(valid_responses[0]);
404        }
405
406        // read the trailing newline
407        loop {
408            let mut skipped = [0u8];
409            match stdin_handle.read_exact(&mut skipped) {
410                Ok(()) if &skipped != b"\n" => continue,
411                _ => break,
412            }
413        }
414
415        if valid_responses.contains(&input[0]) {
416            return Ok(input[0]);
417        } else {
418            writeln!(
419                stderr,
420                "Invalid option: '{}'\n",
421                str::from_utf8(&input).unwrap_or("<INVALID>")
422            )?;
423        }
424    }
425}