From 5eef904d02655a0165203bea1974b7da4ff34990 Mon Sep 17 00:00:00 2001 From: LeChatP Date: Fri, 23 Oct 2026 12:52:19 +0200 Subject: [PATCH 01/88] fix: dosr no longer loses a backchannel message that arrives in parts * fix: the runner and the monitor read their non-blocking socket with read_exact: when a frame arrived in two parts, its length was consumed then dropped on WouldBlock, the next read took the payload as a length, and dosr failed after the command ran with "invalid backchannel message size: 0 bytes" or an rkyv error, then exited 1; with a terminal on a machine with one CPU, it happened on nearly every command (since c524ead, 4.0.2) * fix: the backchannel keeps the bytes of the frame being read until it is complete, and reads no further than that frame, so that poll still reports the next one * fix: a frame is written at once, and a full non-blocking socket is waited for instead of cutting the frame * fix: the monitor ignores WouldBlock on its backchannel instead of leaving its event loop * tests: a frame split at every byte, written byte by byte, two frames in the socket, and a frame sent on a full socket Co-Authored-By: Claude --- exec/src/monitor/backchannel.rs | 245 ++++++++++++++++++++++++++++++-- exec/src/monitor/mod.rs | 3 +- 2 files changed, 236 insertions(+), 12 deletions(-) diff --git a/exec/src/monitor/backchannel.rs b/exec/src/monitor/backchannel.rs index 997787b4..3441dd50 100644 --- a/exec/src/monitor/backchannel.rs +++ b/exec/src/monitor/backchannel.rs @@ -3,10 +3,14 @@ use log::{trace, warn}; use rkyv::{Archive, Deserialize, Serialize}; use std::io::{self, Write}; +use std::os::fd::AsRawFd; use std::os::unix::net::UnixStream; pub const MAX_BACKCHANNEL_MESSAGE_SIZE: usize = 64 * 1024; +/// A frame is the length of the message as a little-endian `u32`, then the message. +const FRAME_HEADER_SIZE: usize = size_of::(); + macro_rules! signals { (core + [$($extra:expr),* $(,)?]) => { &[ @@ -79,6 +83,8 @@ pub enum ParentMessage { pub struct Backchannel { stream: UnixStream, + /// The bytes of the frame being read: on a non-blocking socket, a frame can arrive in parts. + received: Vec, } impl Backchannel { @@ -90,9 +96,18 @@ impl Backchannel { pub fn pair() -> io::Result<(Self, Self)> { let (a, b) = UnixStream::pair()?; // Keep both as blocking - poll() will notify when data is available - Ok((Self { stream: a }, Self { stream: b })) + Ok((Self::new(a), Self::new(b))) + } + + const fn new(stream: UnixStream) -> Self { + Self { + stream, + received: Vec::new(), + } } + /// Write a whole frame. It is written at once, and a full non-blocking socket is waited for: + /// a frame is never cut, whatever the peer reads in between. fn write_frame(&mut self, data: &[u8]) -> io::Result<()> { if data.len() > MAX_BACKCHANNEL_MESSAGE_SIZE { return Err(io::Error::new( @@ -102,29 +117,93 @@ impl Backchannel { } #[allow(clippy::cast_possible_truncation)] let len = (data.len() as u32).to_le_bytes(); + let mut frame = Vec::with_capacity(FRAME_HEADER_SIZE + data.len()); + frame.extend_from_slice(&len); + frame.extend_from_slice(data); trace!("backchannel: write frame len={}", data.len()); - self.stream.write_all(&len)?; - self.stream.write_all(data)?; + let mut written = 0; + while written < frame.len() { + match self.stream.write(&frame[written..]) { + Ok(0) => return Err(io::Error::from(io::ErrorKind::WriteZero)), + Ok(count) => written += count, + Err(ref e) if e.kind() == io::ErrorKind::Interrupted => {} + Err(ref e) if e.kind() == io::ErrorKind::WouldBlock => self.wait_writable()?, + Err(e) => return Err(e), + } + } Ok(()) } + /// Wait until the socket can be written again. + fn wait_writable(&self) -> io::Result<()> { + let mut fd = libc::pollfd { + fd: self.stream.as_raw_fd(), + events: libc::POLLOUT, + revents: 0, + }; + loop { + // SAFETY: `fd` is one valid pollfd that outlives the call, and the count is 1 + if unsafe { libc::poll(&raw mut fd, 1, -1) } >= 0 { + return Ok(()); + } + let error = io::Error::last_os_error(); + if error.kind() != io::ErrorKind::Interrupted { + return Err(error); + } + } + } + + /// Read one frame. On a non-blocking socket, the bytes read so far are kept and `WouldBlock` + /// is returned until the frame is complete. Only the bytes of this frame are read: the next + /// one stays in the socket, so that `poll()` still reports it. fn read_frame(&mut self) -> io::Result> { use std::io::Read; - let mut len_buf = [0u8; 4]; - self.stream.read_exact(&mut len_buf)?; - let len = u32::from_le_bytes(len_buf) as usize; + loop { + let size = self.frame_size()?; + let start = self.received.len(); + if start == size { + trace!("backchannel: read frame len={}", size - FRAME_HEADER_SIZE); + let data = self.received.split_off(FRAME_HEADER_SIZE); + self.received.clear(); + return Ok(data); + } + self.received.resize(size, 0); + match self.stream.read(&mut self.received[start..]) { + Ok(0) => { + self.received.truncate(start); + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "backchannel closed", + )); + } + Ok(count) => self.received.truncate(start + count), + Err(e) => { + self.received.truncate(start); + if e.kind() != io::ErrorKind::Interrupted { + return Err(e); + } + } + } + } + } + + /// The size of the frame being read, header included; the size of the header alone until it + /// is read. An invalid length means that the stream cannot be trusted anymore: the bytes read + /// so far are dropped. + fn frame_size(&mut self) -> io::Result { + let Some(header) = self.received.first_chunk::() else { + return Ok(FRAME_HEADER_SIZE); + }; + let len = u32::from_le_bytes(*header) as usize; if len == 0 || len > MAX_BACKCHANNEL_MESSAGE_SIZE { + self.received.clear(); warn!("backchannel: invalid frame size {len}"); return Err(io::Error::new( io::ErrorKind::InvalidData, format!("invalid backchannel message size: {len} bytes"), )); } - - let mut data = vec![0u8; len]; - trace!("backchannel: read frame len={len}"); - self.stream.read_exact(&mut data)?; - Ok(data) + Ok(FRAME_HEADER_SIZE + len) } /// # Errors @@ -279,4 +358,148 @@ mod tests { .expect_err("truncated frame must fail gracefully"); assert_eq!(error.kind(), io::ErrorKind::InvalidData); } + + /// The bytes of one frame, as the sender writes them. + fn frame_bytes(msg: &ParentMessage) -> Vec { + use std::io::Read; + let (mut sender, mut receiver) = Backchannel::pair().expect("create backchannel pair"); + sender + .send_parent_message(msg) + .expect("send parent message"); + drop(sender); + let mut frame = Vec::new(); + receiver + .get_mut() + .read_to_end(&mut frame) + .expect("read the frame"); + frame + } + + // The monitor can be preempted between the length and the payload of a frame: the runner + // reads a non-blocking socket and must keep what it already read until the frame is complete. + #[test] + fn backchannel_keeps_a_frame_split_across_reads() { + let frame = frame_bytes(&ParentMessage::ExitStatus(0)); + let (mut monitor, mut runner) = Backchannel::pair().expect("create backchannel pair"); + runner.set_nonblocking(true).expect("set nonblocking"); + + for split in 1..frame.len() { + monitor + .get_mut() + .write_all(&frame[..split]) + .expect("write the first part"); + let error = runner + .recv_parent_message() + .expect_err("a part of a frame is not a message"); + assert_eq!(error.kind(), io::ErrorKind::WouldBlock, "split at {split}"); + + monitor + .get_mut() + .write_all(&frame[split..]) + .expect("write the rest"); + match runner.recv_parent_message() { + Ok(ParentMessage::ExitStatus(status)) => assert_eq!(status, 0), + other => panic!("split at {split}: unexpected result {other:?}"), + } + } + } + + #[test] + fn backchannel_reads_a_frame_written_byte_by_byte() { + let frame = frame_bytes(&ParentMessage::CommandPid(4242)); + let (mut monitor, mut runner) = Backchannel::pair().expect("create backchannel pair"); + runner.set_nonblocking(true).expect("set nonblocking"); + + let (last, first) = frame.split_last().expect("a frame is not empty"); + for byte in first { + monitor + .get_mut() + .write_all(std::slice::from_ref(byte)) + .expect("write one byte"); + let error = runner + .recv_parent_message() + .expect_err("a part of a frame is not a message"); + assert_eq!(error.kind(), io::ErrorKind::WouldBlock); + } + monitor + .get_mut() + .write_all(std::slice::from_ref(last)) + .expect("write the last byte"); + match runner.recv_parent_message() { + Ok(ParentMessage::CommandPid(pid)) => assert_eq!(pid, 4242), + other => panic!("unexpected result {other:?}"), + } + } + + // The monitor handles one message per readable event: reading a frame must not take the + // bytes of the next one, or poll would not report them anymore. + #[test] + fn backchannel_leaves_the_next_frame_in_the_socket() { + use std::os::fd::AsRawFd; + let (mut runner, mut monitor) = Backchannel::pair().expect("create backchannel pair"); + monitor.set_nonblocking(true).expect("set nonblocking"); + runner + .send_monitor_message(&MonitorMessage::Signal(libc::SIGUSR1)) + .expect("send a signal"); + runner + .send_monitor_message(&MonitorMessage::Edge) + .expect("send the edge"); + + match monitor.recv_monitor_message() { + Ok(MonitorMessage::Signal(signal)) => assert_eq!(signal, libc::SIGUSR1), + other => panic!("unexpected result {other:?}"), + } + let mut fd = libc::pollfd { + fd: monitor.get_stream().as_raw_fd(), + events: libc::POLLIN, + revents: 0, + }; + // SAFETY: `fd` is one valid pollfd that outlives the call, and the timeout is zero + let ready = unsafe { libc::poll(&raw mut fd, 1, 0) }; + assert_eq!(ready, 1, "the second frame must still be readable"); + assert!(matches!( + monitor.recv_monitor_message(), + Ok(MonitorMessage::Edge) + )); + } + + // A frame sent on a full non-blocking socket waits for room instead of being cut. + #[test] + fn backchannel_waits_for_room_when_the_socket_is_full() { + use std::io::Read; + let (mut monitor, mut runner) = Backchannel::pair().expect("create backchannel pair"); + monitor.set_nonblocking(true).expect("set nonblocking"); + runner + .get_mut() + .set_read_timeout(Some(std::time::Duration::from_secs(10))) + .expect("set a read timeout"); + + let chunk = [0xffu8; 4096]; + let mut filled = 0; + loop { + match monitor.get_mut().write(&chunk) { + Ok(written) => filled += written, + Err(ref e) if e.kind() == io::ErrorKind::WouldBlock => break, + Err(e) => panic!("fill the socket: {e}"), + } + } + // the reader drains the socket only once the frame is being sent on a full socket + let reader = std::thread::spawn(move || { + std::thread::sleep(std::time::Duration::from_millis(200)); + let mut filler = vec![0u8; filled]; + runner + .get_mut() + .read_exact(&mut filler) + .expect("drain the filler"); + runner.recv_parent_message() + }); + + monitor + .send_parent_message(&ParentMessage::ExitStatus(7)) + .expect("the frame is sent whole"); + match reader.join().expect("the reader thread ends") { + Ok(ParentMessage::ExitStatus(status)) => assert_eq!(status, 7), + other => panic!("unexpected result {other:?}"), + } + } } diff --git a/exec/src/monitor/mod.rs b/exec/src/monitor/mod.rs index 6e550094..a925ef85 100644 --- a/exec/src/monitor/mod.rs +++ b/exec/src/monitor/mod.rs @@ -116,7 +116,8 @@ impl Process for MonitorClosure { registry.set_break(io::Error::from_raw_os_error(0)); } } - //Err(ref e) if e.kind() == io::ErrorKind::WouldBlock => { }, + // A frame arrived in parts: the rest comes with the next readable event. + Err(ref e) if e.kind() == io::ErrorKind::WouldBlock => {} Err(e) => { registry.set_break(e); } From 324c50fda2b1935ae585b5d23ce0916ac4d7f748 Mon Sep 17 00:00:00 2001 From: LeChatP Date: Fri, 23 Oct 2026 13:08:42 +0200 Subject: [PATCH 02/88] tests: a stdio terminal that is not dosr's own is not taken as the user's terminal * tests: a pty follower that is not the controlling terminal is a terminal, but is_terminal_for_pgrp refuses it, so the runner gives it to the command as it is instead of proxying it or setting it to raw mode (sudo-rs #1377, #1380) Co-Authored-By: Claude --- exec/src/terminal.rs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/exec/src/terminal.rs b/exec/src/terminal.rs index 186b710a..c5dd6b4a 100644 --- a/exec/src/terminal.rs +++ b/exec/src/terminal.rs @@ -561,4 +561,15 @@ mod tests { .expect("ttyname should work on pty follower"); assert_eq!(ttyname.as_os_str().as_bytes(), pty.path.as_bytes()); } + + // A stdio terminal that is not the controlling terminal of dosr is not the user's terminal: + // it is given to the command as it is, never proxied through the pty nor set to raw mode. + #[test] + fn test_a_terminal_that_is_not_ours_is_not_the_user_terminal() { + let pty = Pty::open().expect("Failed to open PTY"); + assert!(terminal::safe_isatty(&pty.follower)); + // SAFETY: getpgrp does not access memory + let pgrp = ProcessId::new(unsafe { libc::getpgrp() }); + assert!(!pty.follower.is_terminal_for_pgrp(pgrp)); + } } From 70c8209826060ece360fcf5f5a7bc084612a39ac Mon Sep 17 00:00:00 2001 From: LeChatP Date: Fri, 23 Oct 2026 13:22:38 +0200 Subject: [PATCH 03/88] fix: a stopped command suspends dosr like a shell job instead of being killed * fix: when the command was stopped (^Z, SIGSTOP, SIGTTIN, SIGTTOU), the monitor reported an error, dosr exited, and the monitor then killed the stopped command with SIGKILL: ^Z on `dosr vim` lost the editor and its unsaved work * feat: the monitor reports the stop with its signal, and keeps the process group that had the pty; dosr gives the terminal back in cooked mode and stops its job with the same signal, so that the shell sees a stopped job * feat: once dosr is continued (fg or bg), it sets the terminal up again, and the monitor resumes the command with the pty in its foreground when dosr is in the foreground of the user's terminal, in the background otherwise * fix: a command stopped by SIGTTIN or SIGTTOU while dosr is in the foreground is resumed at once, whether the terminal was already raw or not (sudo-rs #1598) * tests: the suspend messages round trip, and a signal action changed while suspending is put back Co-Authored-By: Claude --- exec/src/monitor/backchannel.rs | 39 +++++++- exec/src/monitor/mod.rs | 45 +++++++-- exec/src/pipe.rs | 5 + exec/src/runner.rs | 169 ++++++++++++++++++++++++++++++++ 4 files changed, 248 insertions(+), 10 deletions(-) diff --git a/exec/src/monitor/backchannel.rs b/exec/src/monitor/backchannel.rs index 3441dd50..f2151828 100644 --- a/exec/src/monitor/backchannel.rs +++ b/exec/src/monitor/backchannel.rs @@ -69,12 +69,16 @@ pub enum MonitorMessage { Signal(i32), /// Ready to start Edge, + /// Resume the stopped command, with the pty in its foreground or not + Resume { foreground: bool }, } #[derive(Archive, Serialize, Deserialize, Debug)] pub enum ParentMessage { /// Command PID CommandPid(i32), + /// The command was stopped by this signal: suspend dosr, like a shell job + CommandStopped(i32), /// Exit status from the command ExitStatus(i32), /// Error during execution @@ -285,7 +289,9 @@ mod tests { .expect("receive monitor message") { MonitorMessage::Signal(signal) => assert_eq!(signal, libc::SIGUSR1), - MonitorMessage::Edge => panic!("unexpected monitor message"), + MonitorMessage::Edge | MonitorMessage::Resume { .. } => { + panic!("unexpected monitor message") + } } monitor @@ -296,7 +302,9 @@ mod tests { .expect("receive parent message") { ParentMessage::CommandPid(pid) => assert_eq!(pid, 4242), - ParentMessage::ExitStatus(_) | ParentMessage::Error(_) => { + ParentMessage::ExitStatus(_) + | ParentMessage::Error(_) + | ParentMessage::CommandStopped(_) => { panic!("unexpected parent message") } } @@ -306,6 +314,33 @@ mod tests { .expect("keep nonblocking mode configurable"); } + #[test] + fn backchannel_roundtrips_the_suspend_messages() { + let (mut parent, mut monitor) = Backchannel::pair().expect("create backchannel pair"); + + monitor + .send_parent_message(&ParentMessage::CommandStopped(libc::SIGTSTP)) + .expect("send the stop"); + assert!(matches!( + parent.recv_parent_message(), + Ok(ParentMessage::CommandStopped(libc::SIGTSTP)) + )); + + for foreground in [true, false] { + parent + .send_monitor_message(&MonitorMessage::Resume { foreground }) + .expect("send the resume"); + match monitor.recv_monitor_message() { + Ok(MonitorMessage::Resume { + foreground: received, + }) => { + assert_eq!(received, foreground); + } + other => panic!("unexpected result {other:?}"), + } + } + } + #[test] fn backchannel_rejects_oversized_frames() { let (mut parent, _) = Backchannel::pair().expect("create backchannel pair"); diff --git a/exec/src/monitor/mod.rs b/exec/src/monitor/mod.rs index a925ef85..57a2ab52 100644 --- a/exec/src/monitor/mod.rs +++ b/exec/src/monitor/mod.rs @@ -7,7 +7,7 @@ use crate::event::{EventRegistry, PollEvent, Process}; use crate::orchestrator::{Orchestrator, PreExecContext}; use crate::pty::PtyFollower; use crate::signal::{SignalInfo, SignalStream, register_signal_handler}; -use crate::terminal::TerminalExt; +use crate::terminal::{ProcessId, TerminalExt}; use libc::{SIGCHLD, SIGKILL}; use log::{debug, error, trace, warn}; @@ -116,6 +116,7 @@ impl Process for MonitorClosure { registry.set_break(io::Error::from_raw_os_error(0)); } } + Ok(MonitorMessage::Resume { foreground }) => self.resume_command(foreground), // A frame arrived in parts: the rest comes with the next readable event. Err(ref e) if e.kind() == io::ErrorKind::WouldBlock => {} Err(e) => { @@ -128,6 +129,29 @@ impl Process for MonitorClosure { } impl MonitorClosure { + /// Resume the stopped command once dosr is continued: in the foreground, the pty goes back to + /// the process group of the command; in the background, the monitor keeps it. + fn resume_command(&self, foreground: bool) { + let Some(pid) = self.command_pid else { + return; + }; + let pgrp = if foreground { + self.command_pgrp + } else { + self.monitor_pgrp + }; + debug!("monitor: resuming the command (foreground={foreground})"); + if let Err(e) = self + .pty_follower + .as_tty() + .and_then(|tty| tty.tcsetpgrp(ProcessId::new(pgrp))) + { + warn!("monitor: cannot give the pty to process group {pgrp}: {e}"); + } + // SAFETY: killpg does not access memory, it only sends a signal to the command's group + unsafe { libc::killpg(pid, libc::SIGCONT) }; + } + fn handle_signal(&mut self, info: SignalInfo, registry: &mut EventRegistry) { debug!("monitor: got signal {} from pid {}", info.signal, info.pid); if info.signal == SIGCHLD { @@ -138,13 +162,18 @@ impl MonitorClosure { unsafe { libc::waitpid(pid, &raw mut status, libc::WNOHANG | libc::WUNTRACED) }; if res > 0 { if libc::WIFSTOPPED(status) { - warn!("monitor: command stopped with {}", libc::WSTOPSIG(status)); - let _ = - self.backchannel - .send_parent_message(&ParentMessage::Error(format!( - "Command stopped with signal {}", - libc::WSTOPSIG(status) - ))); + let signal = libc::WSTOPSIG(status); + debug!("monitor: command stopped with {signal}"); + // The command may have given the pty to another of its process groups: + // that group gets the pty back when the command is resumed. + if let Ok(pgrp) = self.pty_follower.as_tty().and_then(|tty| tty.tcgetpgrp()) + && pgrp.inner() != self.monitor_pgrp + { + self.command_pgrp = pgrp.inner(); + } + let _ = self + .backchannel + .send_parent_message(&ParentMessage::CommandStopped(signal)); return; } diff --git a/exec/src/pipe.rs b/exec/src/pipe.rs index 00f6d3f9..946a499b 100644 --- a/exec/src/pipe.rs +++ b/exec/src/pipe.rs @@ -90,6 +90,11 @@ impl Pipe { self.background = true; } + pub fn enable_input(&mut self, registry: &mut EventRegistry) { + self.buffer_lr.read_handle.resume(registry); + self.background = false; + } + pub fn resume_events(&mut self, registry: &mut EventRegistry) { if !self.background { self.buffer_lr.read_handle.resume(registry); diff --git a/exec/src/runner.rs b/exec/src/runner.rs index 9273f5d1..f3108029 100644 --- a/exec/src/runner.rs +++ b/exec/src/runner.rs @@ -188,6 +188,19 @@ impl ExecRunner { debug!("runner: command pid set to {pid}"); self.command_pid = pid; } + Ok(ParentMessage::CommandStopped(signal)) => { + debug!("runner: command stopped by signal {signal}, suspending"); + if let Some(foreground) = self.suspend(signal, registry) + && self + .backchannel + .send_monitor_message(&MonitorMessage::Resume { foreground }) + .is_err() + { + warn!("runner: failed to resume the command, checking monitor exit"); + self.check_monitor_exit(registry); + } + self.pipe.resume_events(registry); + } Ok(ParentMessage::ExitStatus(status)) => { debug!("runner: exit status received: {status}"); registry.set_exit(std::process::ExitStatus::from_raw(status)); @@ -232,6 +245,103 @@ impl ExecRunner { Ok(()) } + /// Suspend dosr with the stopped command, like a shell job, and tell how to resume the + /// command once dosr is continued: `Some(true)` with the pty in its foreground, `Some(false)` + /// in the background, `None` to leave it stopped. + fn suspend(&mut self, signal: libc::c_int, registry: &mut EventRegistry) -> Option { + // A SIGCONT received while suspending would resume the terminal a second time. + let _ignore_sigcont = SignalDisposition::set(libc::SIGCONT, libc::SIG_IGN) + .inspect_err(|e| warn!("runner: cannot ignore SIGCONT: {e}")) + .ok(); + + if matches!(signal, libc::SIGTTOU | libc::SIGTTIN) { + // The command wants the terminal: when dosr is in the foreground, the command is + // resumed at once, whether the terminal was already raw or not (sudo-rs #1598). + if !self.foreground && self.check_foreground().is_err() { + // the user's terminal was revoked + return None; + } + if self.foreground { + if !self.term_raw + && self + .pipe + .left_mut() + .set_raw_mode(true, self.preserve_oflag) + .is_ok() + { + self.term_raw = true; + } + self.pipe.enable_input(registry); + return Some(true); + } + } + + self.pipe.ignore_events(registry); + if self.term_raw { + match self.pipe.left_mut().restore(false) { + Ok(()) => self.term_raw = false, + Err(e) => warn!("runner: cannot restore the terminal settings: {e}"), + } + } + + // Stop the job of dosr with the same signal, with its default action. + let default_action = if signal == libc::SIGSTOP { + None + } else { + SignalDisposition::set(signal, libc::SIG_DFL) + .inspect_err(|e| warn!("runner: cannot set the default action of {signal}: {e}")) + .ok() + }; + let pgrp = self.parent_pgrp.inner(); + // SAFETY: getpid, kill and killpg do not access memory + let suspended = unsafe { + (pgrp == libc::getpid() || libc::kill(pgrp, 0) == 0) && libc::killpg(pgrp, signal) == 0 + }; + drop(default_action); + + if !suspended { + error!("runner: no process group to suspend, terminating the command"); + if self.command_pid > 0 { + // SAFETY: killpg does not access memory, it only sends a signal to the command's group + unsafe { libc::killpg(self.command_pid, libc::SIGKILL) }; + } + return None; + } + // dosr was continued + if self.command_pid <= 0 || self.resume_after_suspend().is_err() { + return None; + } + Some(self.term_raw) + } + + /// Set the terminals up again when dosr is continued after a suspension. + fn resume_after_suspend(&mut self) -> io::Result<()> { + self.check_foreground()?; + // the user may have changed the terminal settings while dosr was stopped + self.pipe.left().copy_to(self.pipe.right())?; + if self.foreground { + if self + .pipe + .left_mut() + .set_raw_mode(true, self.preserve_oflag) + .is_ok() + { + self.term_raw = true; + } + } else { + // in the background, dosr cannot use the terminal + self.term_raw = false; + } + Ok(()) + } + + /// Check whether dosr is in the foreground of the user's terminal. + fn check_foreground(&mut self) -> io::Result<()> { + let pgrp = self.pipe.left().as_tty()?.tcgetpgrp()?; + self.foreground = pgrp == self.parent_pgrp; + Ok(()) + } + fn resume_terminal(&mut self) -> io::Result<()> { if self.term_raw && self.foreground { debug!( @@ -645,6 +755,42 @@ pub fn run_with_pty( } } +/// Changes the action of a signal until it is dropped, then puts the previous action back. +struct SignalDisposition { + signal: libc::c_int, + previous: libc::sigaction, +} + +impl SignalDisposition { + fn set(signal: libc::c_int, action: libc::sighandler_t) -> io::Result { + // SAFETY: sigaction is a plain C struct, for which all zeroes is a valid value + let mut new: libc::sigaction = unsafe { std::mem::zeroed() }; + new.sa_sigaction = action; + // SAFETY: sigaction is a plain C struct, for which all zeroes is a valid value + let mut previous: libc::sigaction = unsafe { std::mem::zeroed() }; + // SAFETY: both pointers reference valid sigaction structs that outlive the call + if unsafe { libc::sigaction(signal, &raw const new, &raw mut previous) } == -1 { + return Err(io::Error::last_os_error()); + } + Ok(Self { signal, previous }) + } +} + +impl Drop for SignalDisposition { + fn drop(&mut self) { + // SAFETY: `previous` was filled by sigaction for this signal, and outlives the call + if unsafe { libc::sigaction(self.signal, &raw const self.previous, std::ptr::null_mut()) } + == -1 + { + warn!( + "runner: cannot restore the action of signal {}: {}", + self.signal, + io::Error::last_os_error() + ); + } + } +} + fn block_all_signals() -> Option { // SAFETY: sigset_t is a plain bitmask, for which all zeroes is a valid value let mut set = unsafe { std::mem::zeroed::() }; @@ -791,6 +937,29 @@ mod tests { let _ = fs::remove_file(path); } + // suspending dosr sets SIGCONT to ignored and the stop signal to its default action, and must + // put the previous actions back once dosr is continued + #[test] + fn signal_disposition_restores_the_previous_action() { + fn action(signal: libc::c_int) -> libc::sighandler_t { + let mut current: libc::sigaction = unsafe { std::mem::zeroed() }; + assert_eq!( + unsafe { libc::sigaction(signal, std::ptr::null(), &raw mut current) }, + 0 + ); + current.sa_sigaction + } + + let _guard = MUTEX.lock().unwrap(); + let before = action(libc::SIGUSR2); + { + let _ignored = super::SignalDisposition::set(libc::SIGUSR2, libc::SIG_IGN) + .expect("ignore SIGUSR2"); + assert_eq!(action(libc::SIGUSR2), libc::SIG_IGN); + } + assert_eq!(action(libc::SIGUSR2), before); + } + #[test] fn run_no_pty_returns_command_exit_code() { let _guard = MUTEX.lock().unwrap(); From ae0b7059c2eb1b4beaa2125b0612c70f38eeae03 Mon Sep 17 00:00:00 2001 From: LeChatP Date: Fri, 23 Oct 2026 13:32:33 +0200 Subject: [PATCH 04/88] fix: two dosr of one pipeline no longer read their passwords from the terminal at the same time * fix: with `dosr a | dosr b`, both prompts read /dev/tty at once, each one got part of the keystrokes and both authentications failed; dosr now holds an exclusive flock on its terminal while it authenticates, and the other one waits, then checks its timestamp once it gets the lock (sudo-rs #1650, #1651) * fix: no lock with -S (the password comes from stdin) or when authentication is skipped; without a terminal, or when the lock cannot be taken, dosr goes on without it * tests: the lock is exclusive between two opens of the terminal and released on drop, and there is none without a terminal Co-Authored-By: Claude --- src/dosr/pam/mod.rs | 68 +++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 66 insertions(+), 2 deletions(-) diff --git a/src/dosr/pam/mod.rs b/src/dosr/pam/mod.rs index 0d52e756..9395b112 100644 --- a/src/dosr/pam/mod.rs +++ b/src/dosr/pam/mod.rs @@ -1,4 +1,11 @@ -use std::{borrow::Cow, ffi::CStr}; +use std::{ + borrow::Cow, + ffi::CStr, + fs::{File, OpenOptions}, + io, + os::{fd::AsRawFd, unix::fs::OpenOptionsExt}, + path::Path, +}; use bon::Builder; use log::{debug, error, info, warn}; @@ -136,12 +143,48 @@ fn set_pam_items(txn: &mut impl PamShared, user: &Cred) -> PamResult<()> { Ok(()) } +/// An exclusive lock on the user's terminal, released when dropped (when its file is closed). +struct TerminalLock { + _terminal: File, +} + +/// Lock the terminal at `path`, waiting while another dosr of the same pipeline authenticates on +/// it. `None` when there is no terminal or it cannot be locked: dosr then goes on without it. +fn lock_terminal(path: &Path) -> Option { + let terminal = OpenOptions::new() + .read(true) + .custom_flags(libc::O_NOCTTY) + .open(path) + .ok()?; + loop { + // SAFETY: flock does not access memory, and the descriptor stays open during the call + if unsafe { libc::flock(terminal.as_raw_fd(), libc::LOCK_EX) } == 0 { + return Some(TerminalLock { + _terminal: terminal, + }); + } + let error = io::Error::last_os_error(); + if error.kind() != io::ErrorKind::Interrupted { + debug!("cannot lock the terminal: {error}"); + return None; + } + } +} + pub fn start_session<'a>( authentication: SAuthentication, #[cfg_attr(not(feature = "timeout"), allow(unused_variables))] timeout: &STimeout, user: &Cred, cli: &Cli, ) -> SrResult>, SessionActive>> { + // `dosr a | dosr b` would read both passwords from the terminal at the same time, each one + // getting part of the keystrokes: the terminal is locked until this authentication is done, + // and the other dosr checks its timestamp once it gets the lock (sudo-rs #1650). + let _terminal_lock = if authentication.is_skip() || cli.stdin { + None + } else { + lock_terminal(Path::new("/dev/tty")) + }; let need_password_auth = if authentication.is_skip() { warn!("Skipping password authentication (PAM session-only mode)."); false @@ -226,7 +269,7 @@ mod tests { }; use rootasrole_exec::terminal::TerminalExt; use serde_json::Map; - use std::{ffi::OsStr, sync::Mutex}; + use std::{ffi::OsStr, os::unix::ffi::OsStrExt, sync::Mutex}; use test_log::test; static PAM_TEST_LOCK: Mutex<()> = Mutex::new(()); @@ -486,4 +529,25 @@ mod tests { assert!(start_session(auth, &timeout_ppid, &user, &cli).is_ok()); assert!(start_session(auth, &timeout_tty, &user, &cli).is_ok()); } + + // Another dosr of the same pipeline opens the terminal on its own: while one holds the lock, + // the other cannot take it, and it can once the lock is dropped. + #[test] + fn test_terminal_lock_is_exclusive_and_released_on_drop() { + let pty = rootasrole_exec::pty::Pty::open().expect("open a pty"); + let path = Path::new(OsStr::from_bytes(pty.path.as_bytes())); + let other = File::open(path).expect("open the terminal again"); + // SAFETY: flock does not access memory, and `other` stays open while the closure lives + let try_lock = || unsafe { libc::flock(other.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) }; + + let lock = lock_terminal(path).expect("lock the terminal"); + assert_eq!(try_lock(), -1, "the terminal is locked"); + drop(lock); + assert_eq!(try_lock(), 0, "the lock is released"); + } + + #[test] + fn test_no_terminal_lock_without_a_terminal() { + assert!(lock_terminal(Path::new("/nonexistent/tty")).is_none()); + } } From 14bb6d72aa51dd474a3c73393e8b526507d462b5 Mon Sep 17 00:00:00 2001 From: LeChatP Date: Fri, 23 Oct 2026 14:00:48 +0200 Subject: [PATCH 05/88] fix: dosr stops a command like sudo, giving it time to clean up before SIGKILL * fix: when dosr has to stop a command itself (SIGALRM, the monitor done while the command runs, no job to suspend), it sent SIGKILL at once, so a package manager or a database could not remove its lock or finish a write * fix: terminate_process sends SIGHUP, SIGTERM, then SIGKILL two seconds later, to the command's process group when the monitor is done or the suspension fails, to the command alone for SIGALRM, like sudo's terminate_command * fix: terminate_process refuses a pid of 0 or less, which would signal dosr's own group or every process * tests: a command that handles the signals cleans up and exits, one that ignores them is killed after the delay Co-Authored-By: Claude --- exec/src/monitor/mod.rs | 15 +++----- exec/src/runner.rs | 12 ++---- exec/src/signal.rs | 82 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 91 insertions(+), 18 deletions(-) diff --git a/exec/src/monitor/mod.rs b/exec/src/monitor/mod.rs index 57a2ab52..42d913f1 100644 --- a/exec/src/monitor/mod.rs +++ b/exec/src/monitor/mod.rs @@ -6,9 +6,9 @@ use std::process::Command; use crate::event::{EventRegistry, PollEvent, Process}; use crate::orchestrator::{Orchestrator, PreExecContext}; use crate::pty::PtyFollower; -use crate::signal::{SignalInfo, SignalStream, register_signal_handler}; +use crate::signal::{SignalInfo, SignalStream, register_signal_handler, terminate_process}; use crate::terminal::{ProcessId, TerminalExt}; -use libc::{SIGCHLD, SIGKILL}; +use libc::SIGCHLD; use log::{debug, error, trace, warn}; pub mod backchannel; @@ -97,8 +97,7 @@ impl Process for MonitorClosure { if is_forward_signal_allowed(sig) { if let Some(pid) = self.command_pid { if sig == libc::SIGALRM { - // SAFETY: kill does not access memory, it only sends a signal to the command - unsafe { libc::kill(pid, libc::SIGKILL) }; + terminate_process(pid, false); } else { // SAFETY: kill does not access memory, it only sends a signal to the command unsafe { libc::kill(pid, sig) }; @@ -197,8 +196,7 @@ impl MonitorClosure { if is_forward_signal_allowed(info.signal) { debug!("monitor: forwarding signal {}", info.signal); if info.signal == libc::SIGALRM { - // SAFETY: kill does not access memory, it only sends a signal to the command - unsafe { libc::kill(pid, libc::SIGKILL) }; + terminate_process(pid, false); } else { // SAFETY: kill does not access memory, it only sends a signal to the command unsafe { libc::kill(pid, info.signal) }; @@ -431,10 +429,9 @@ pub fn exec_monitor_process( monitor.monitor_pgrp ); - // Cleanup + // Cleanup: the command still runs although the monitor is done (the runner is gone) if let Some(pid) = monitor.command_pid { - // SAFETY: kill does not access memory, it only sends a signal to the command - unsafe { libc::kill(pid, SIGKILL) }; + terminate_process(pid, true); let mut status = 0; // SAFETY: waitpid only writes the exit status through a pointer to a local integer unsafe { libc::waitpid(pid, &raw mut status, 0) }; diff --git a/exec/src/runner.rs b/exec/src/runner.rs index f3108029..402ae7a7 100644 --- a/exec/src/runner.rs +++ b/exec/src/runner.rs @@ -13,7 +13,7 @@ use crate::orchestrator::{Orchestrator, PreExecContext}; use super::event::{EventRegistry, PollEvent, Process, StopReason}; use super::pipe::{IoLogger, Pipe, io_logger_sealed}; use super::pty::{Pty, PtyLeader}; -use super::signal::{SignalStream, register_signal_handler}; +use super::signal::{SignalStream, register_signal_handler, terminate_process}; use super::terminal::{ProcessId, TermSize, TerminalExt, UserTerm}; pub struct SimpleFileLogger { @@ -301,10 +301,7 @@ impl ExecRunner { if !suspended { error!("runner: no process group to suspend, terminating the command"); - if self.command_pid > 0 { - // SAFETY: killpg does not access memory, it only sends a signal to the command's group - unsafe { libc::killpg(self.command_pid, libc::SIGKILL) }; - } + terminate_process(self.command_pid, true); return None; } // dosr was continued @@ -459,10 +456,7 @@ pub fn run_no_pty( return Ok(status); } } - libc::SIGALRM => { - // SAFETY: kill does not access memory, it only sends a signal to the command - unsafe { libc::kill(child_pid, libc::SIGKILL) }; - } + libc::SIGALRM => terminate_process(child_pid, false), libc::SIGWINCH => { // SAFETY: kill does not access memory, it only sends a signal to the command unsafe { libc::kill(child_pid, libc::SIGWINCH) }; diff --git a/exec/src/signal.rs b/exec/src/signal.rs index 9c582ae7..bf03dd34 100644 --- a/exec/src/signal.rs +++ b/exec/src/signal.rs @@ -229,6 +229,35 @@ pub fn register_signal_handler(signal: SignalNumber) -> io::Result<()> { Ok(()) } +/// How long a command is given to end after `SIGHUP` and `SIGTERM`, before `SIGKILL`, like sudo. +pub const TERMINATION_DELAY: std::time::Duration = std::time::Duration::from_secs(2); + +/// Stop a command with increasing urgency, like sudo's `terminate_command`. +/// +/// `SIGHUP`, `SIGTERM`, then `SIGKILL` once [`TERMINATION_DELAY`] has passed, so that the command +/// can clean up first. With `process_group`, the signals go to the process group `pid` instead of +/// the process. +pub fn terminate_process(pid: libc::pid_t, process_group: bool) { + // kill(0) and killpg(0) would signal dosr's own group, kill(-1) every process it may signal + if pid <= 0 { + return; + } + let send = |signal| { + // SAFETY: kill and killpg do not access memory, they only send a signal + unsafe { + if process_group { + libc::killpg(pid, signal) + } else { + libc::kill(pid, signal) + } + }; + }; + send(libc::SIGHUP); + send(libc::SIGTERM); + std::thread::sleep(TERMINATION_DELAY); + send(libc::SIGKILL); +} + #[allow(clippy::undocumented_unsafe_blocks)] #[cfg(test)] mod tests { @@ -236,6 +265,59 @@ mod tests { MUTEX, signal::{self, SignalStream}, }; + use std::os::unix::process::{CommandExt, ExitStatusExt}; + use std::process::Command; + use std::time::{Duration, Instant}; + + /// A shell in its own process group, given time to set its traps up. + fn spawn_shell(script: &str) -> (std::process::Child, libc::pid_t) { + let child = Command::new("sh") + .arg("-c") + .arg(script) + .process_group(0) + .spawn() + .expect("spawn the shell"); + std::thread::sleep(Duration::from_millis(300)); + let pid = libc::pid_t::try_from(child.id()).expect("pid in range"); + (child, pid) + } + + // A command that handles SIGHUP or SIGTERM cleans up and ends before SIGKILL. + #[test] + fn terminate_process_lets_the_command_clean_up() { + let _guard = MUTEX.lock().unwrap(); + let marker = + std::env::temp_dir().join(format!("rar-terminate-cleanup-{}", std::process::id())); + let _ = std::fs::remove_file(&marker); + let (mut child, pid) = spawn_shell(&format!( + "trap 'echo done > {}; exit 3' HUP TERM; while :; do sleep 0.1; done", + marker.display() + )); + + signal::terminate_process(pid, true); + let status = child.wait().expect("wait for the shell"); + let cleaned = marker.exists(); + let _ = std::fs::remove_file(&marker); + assert_eq!( + status.code(), + Some(3), + "the shell ended on its own: {status:?}" + ); + assert!(cleaned, "the shell cleaned up"); + } + + // A command that ignores SIGHUP and SIGTERM is killed once the delay has passed. + #[test] + fn terminate_process_kills_a_command_that_ignores_the_signals() { + let _guard = MUTEX.lock().unwrap(); + let (mut child, pid) = spawn_shell("trap '' HUP TERM; while :; do sleep 0.1; done"); + + let start = Instant::now(); + signal::terminate_process(pid, true); + let status = child.wait().expect("wait for the shell"); + assert_eq!(status.signal(), Some(libc::SIGKILL)); + assert!(start.elapsed() >= signal::TERMINATION_DELAY); + } #[test] fn test_signals_combined() { From 3d4d19d38ad6c8c689bf3190eaeefd99ccd31a34 Mon Sep 17 00:00:00 2001 From: LeChatP Date: Fri, 23 Oct 2026 14:05:10 +0200 Subject: [PATCH 06/88] chore: bump to 4.2.0 * chore: the next release is 4.2.0, since it adds the password retries (a new policy option) * feat: migration edge from 4.1.0 to 4.2.0, without any change for now; every released version needs one, otherwise its documents cannot be migrated * tests: a 4.1.0 document goes up to 4.2.0 and back down unchanged, but for its version Co-Authored-By: Claude --- Cargo.lock | 8 ++++---- Cargo.toml | 6 +++--- core/src/database/migrations.rs | 36 +++++++++++++++++++++++++++++++++ resources/arch/PKGBUILD | 2 +- 4 files changed, 44 insertions(+), 8 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 44049680..c8b79c8d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -800,7 +800,7 @@ dependencies = [ [[package]] name = "rootasrole" -version = "4.1.0" +version = "4.2.0" dependencies = [ "bitflags 2.13.2", "bon", @@ -834,7 +834,7 @@ dependencies = [ [[package]] name = "rootasrole-core" -version = "4.1.0" +version = "4.2.0" dependencies = [ "bitflags 2.13.2", "bon", @@ -856,7 +856,7 @@ dependencies = [ [[package]] name = "rootasrole-exec" -version = "4.1.0" +version = "4.2.0" dependencies = [ "libc", "log", @@ -1065,7 +1065,7 @@ checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" [[package]] name = "xtask" -version = "4.1.0" +version = "4.2.0" dependencies = [ "anyhow", "capctl", diff --git a/Cargo.toml b/Cargo.toml index 2394bacb..a88ff23e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,7 @@ exclude = ["fuzz", "book", "sudoers-reader"] [workspace.package] edition = "2024" rust-version = "1.93.0" -version = "4.1.0" +version = "4.2.0" license = "LGPL-3.0-or-later" homepage = "https://lechatp.github.io/RootAsRole/" repository = "https://github.com/LeChatP/RootAsRole" @@ -97,8 +97,8 @@ undocumented_unsafe_blocks = { level = "warn", priority = 2 } allow-unwrap-in-tests = true [workspace.dependencies] -rootasrole-core = { version = "4.1.0", path = "core" } -rootasrole-exec = { version = "4.1.0", path = "exec" } +rootasrole-core = { version = "4.2.0", path = "core" } +rootasrole-exec = { version = "4.2.0", path = "exec" } [dependencies] rootasrole-core = { workspace = true } diff --git a/core/src/database/migrations.rs b/core/src/database/migrations.rs index 15211d1d..8e4d4a31 100644 --- a/core/src/database/migrations.rs +++ b/core/src/database/migrations.rs @@ -69,6 +69,12 @@ pub const DOCUMENT_MIGRATIONS: &[Migration] = &[ Ok(()) }, }, + Migration { + from: || Version::new(4, 1, 0), + to: || Version::new(4, 2, 0), + up: unchanged, + down: unchanged, + }, ]; /// `LC_.*` -> `LC_*`: the simple regexes used before 4.1.0 become wildcards. @@ -587,6 +593,36 @@ mod tests { assert_eq!(document["r"][0]["t"][0]["c"], json!(["/usr/bin/id ^$"])); } + #[test] + fn test_4_1_0_documents_up_and_down() { + let original = json!({ + "version": "4.1.0", + "options": {"env": {"default": "delete", "check": ["LC_*"]}, "path": {"default": "keep-safe"}}, + "roles": [{"name": "r", "tasks": [{"name": "t", "commands": ["/usr/bin/ls ^.*$"]}]}] + }); + let mut document = original.clone(); + let migration = migrate_document(&mut document).unwrap().unwrap(); + assert_eq!(migration.from, Version::new(4, 1, 0)); + assert_eq!(document["version"], PACKAGE_VERSION.to_string()); + let mut unchanged = original.clone(); + unchanged["version"] = document["version"].clone(); + assert_eq!( + document, unchanged, + "4.1.0 to 4.2.0 changes nothing but the version" + ); + + Migration::migrate_from( + &PACKAGE_VERSION, + &Version::new(4, 1, 0), + &mut document, + DOCUMENT_MIGRATIONS, + ) + .unwrap(); + assert_eq!(document["version"], PACKAGE_VERSION.to_string()); + document["version"] = original["version"].clone(); + assert_eq!(document, original); + } + #[test] fn test_path_behaviors_up_and_down() { let mut document = json!({ diff --git a/resources/arch/PKGBUILD b/resources/arch/PKGBUILD index 78772780..d2b8cb67 100644 --- a/resources/arch/PKGBUILD +++ b/resources/arch/PKGBUILD @@ -1,7 +1,7 @@ # Maintainer: Eddie Billoir pkgname=rootasrole -pkgver=4.1.0 +pkgver=4.2.0 pkgrel=1 pkgdesc='Alternative to sudo to run some administrative commands that uses Linux capabilities and RBAC for scalability.' url='https://lechatp.github.io/RootAsRole/' From 3460db4f9431941f3ea3c0606ad5e1b5d8c6abe8 Mon Sep 17 00:00:00 2001 From: LeChatP Date: Fri, 23 Oct 2026 14:13:45 +0200 Subject: [PATCH 07/88] fix: a duration of the policy is refused when a field has a sign or more fields follow * fix: "-00:00:01" was read as one second, since "-00" parses as 0 and the negative check missed it; "+00:00:01" and "00:05:00:junk" were accepted too, the fourth field ignored * fix: a duration is exactly three fields of digits, hh:mm:ss * tests: signs on a zero field, a plus sign, extra, empty and missing fields are refused Co-Authored-By: Claude --- core/src/database/mod.rs | 51 ++++++++++++++++++++++++++-------------- 1 file changed, 33 insertions(+), 18 deletions(-) diff --git a/core/src/database/mod.rs b/core/src/database/mod.rs index eb04fb7a..5ed5e45b 100644 --- a/core/src/database/mod.rs +++ b/core/src/database/mod.rs @@ -126,25 +126,26 @@ where } fn convert_string_to_duration(s: &str) -> Result, Box> { - let mut parts = s.split(':'); - //unwrap or error - if let (Some(hours), Some(minutes), Some(seconds)) = (parts.next(), parts.next(), parts.next()) - { - let hours: i64 = hours.parse()?; - let minutes: i64 = minutes.parse()?; - let seconds: i64 = seconds.parse()?; - if hours < 0 || minutes < 0 || seconds < 0 { - return Err("A duration cannot be negative".into()); + // hh:mm:ss, exactly three fields of digits: a sign is never valid, and would be lost on a + // zero field ("-00" reads as 0), and nothing may follow the seconds + let fields: Vec<&str> = s.split(':').collect(); + let [hours, minutes, seconds] = fields.as_slice() else { + return Err("Invalid duration format, expected hh:mm:ss".into()); + }; + let field = |field: &str| -> Result> { + if field.is_empty() || !field.bytes().all(|byte| byte.is_ascii_digit()) { + return Err(format!("Invalid duration field `{field}`, expected digits").into()); } - // checked: a value of the policy must be an error, never a panic of the parser - let total = hours - .checked_mul(3600) - .and_then(|total| total.checked_add(minutes.checked_mul(60)?)) - .and_then(|total| total.checked_add(seconds)) - .ok_or("The duration is too long")?; - return Ok(Some(jiff::SignedDuration::from_secs(total))); - } - Err("Invalid duration format".into()) + Ok(field.parse()?) + }; + let (hours, minutes, seconds) = (field(hours)?, field(minutes)?, field(seconds)?); + // checked: a value of the policy must be an error, never a panic of the parser + let total = hours + .checked_mul(3600) + .and_then(|total| total.checked_add(minutes.checked_mul(60)?)) + .and_then(|total| total.checked_add(seconds)) + .ok_or("The duration is too long")?; + Ok(Some(jiff::SignedDuration::from_secs(total))) } #[cfg(test)] @@ -167,6 +168,20 @@ mod tests { ] { assert!(convert_string_to_duration(duration).is_err(), "{duration}"); } + // a sign is never valid, not even on a zero field where it would be lost, nor anything + // after the seconds + for duration in [ + "-00:00:01", + "+00:00:01", + "00:-0:01", + "00:05:00:00", + "00:05:00:junk", + "00::05", + "00:05", + "", + ] { + assert!(convert_string_to_duration(duration).is_err(), "{duration}"); + } } struct IndexSetTester(pub Option>); From b1092a10e35e3416dadadd9d3e18a210912fa4ac Mon Sep 17 00:00:00 2001 From: LeChatP Date: Fri, 23 Oct 2026 14:15:00 +0200 Subject: [PATCH 08/88] feat: dosr asks for the password up to 3 times, waiting 5 seconds after a wrong one * feat: a wrong password (PAM_AUTH_ERR) is asked again, after a delay, with "Sorry, try again."; any other PAM error, such as the end of the input or an unknown user, stops at once, without delay * feat: the tries and the delay are build-time defaults, RAR_PASSWORD_TRIES (3) and RAR_PASSWORD_DELAY (00:00:05), and a policy option at every level, "password": {"tries": 3, "delay": "00:00:05"}, each field inherited on its own * feat: tries is 1 to 255, 0 is refused: it would never let anyone in, or never stop asking (sudo-rs #1309); a build value out of range stops the build * feat: the downgrade to 4.1.0 removes the password option, which 4.1.0 does not know * tests: the option's format and defaults, its CBOR round trip, the inheritance of each field, the migrations, and the retries until success, up to the last try, and stopping on another error Co-Authored-By: Claude --- .cargo/config.toml | 2 + core/src/database/migrations.rs | 53 +++++++++++++- core/src/database/options.rs | 94 +++++++++++++++++++++++- core/src/util.rs | 21 ++++++ src/dosr/finder/mod.rs | 6 ++ src/dosr/finder/options.rs | 84 ++++++++++++++++++++-- src/dosr/lib.rs | 8 ++- src/dosr/pam/mod.rs | 123 ++++++++++++++++++++++++++++++-- 8 files changed, 376 insertions(+), 15 deletions(-) diff --git a/.cargo/config.toml b/.cargo/config.toml index fcd04d69..a126cbdf 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -48,6 +48,8 @@ RAR_CHSR_EDITOR_PATH = "/usr/bin/vim" RAR_TIMEOUT_TYPE = "ppid" RAR_TIMEOUT_DURATION = "00:05:00" RAR_TIMEOUT_MAX_USAGE = "" +RAR_PASSWORD_TRIES = "3" +RAR_PASSWORD_DELAY = "00:00:05" RAR_PATH_DEFAULT = "delete" RAR_PATH_ADD_LIST = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/snap/bin" RAR_PATH_REMOVE_LIST = "" diff --git a/core/src/database/migrations.rs b/core/src/database/migrations.rs index 8e4d4a31..4660a641 100644 --- a/core/src/database/migrations.rs +++ b/core/src/database/migrations.rs @@ -73,7 +73,10 @@ pub const DOCUMENT_MIGRATIONS: &[Migration] = &[ from: || Version::new(4, 1, 0), to: || Version::new(4, 2, 0), up: unchanged, - down: unchanged, + down: |_, document| { + remove_password_options(document); + Ok(()) + }, }, ]; @@ -172,6 +175,22 @@ fn rewrite_path_behaviors(document: &mut Value, rewrite: fn(&str) -> Option<&'st } /// Whether a command of the policy is only a path, without arguments. +/// 4.1.0 has no `password` option: it is removed from the options of every level. +fn remove_password_options(document: &mut Value) { + match document { + Value::Object(map) => { + if let Some(Value::Object(options)) = map.get_mut("options") + && options.remove("password").is_some() + { + info!("Removing the password option, unknown before 4.2.0"); + } + map.values_mut().for_each(remove_password_options); + } + Value::Array(values) => values.iter_mut().for_each(remove_password_options), + _ => {} + } +} + fn path_only(command: &str) -> bool { let command = command.trim(); !command.is_empty() && !command.contains(char::is_whitespace) @@ -593,6 +612,38 @@ mod tests { assert_eq!(document["r"][0]["t"][0]["c"], json!(["/usr/bin/id ^$"])); } + #[test] + fn test_password_option_up_and_down() { + let mut document = json!({ + "options": {"password": {"tries": 5}, "timeout": {"type": "tty"}}, + "roles": [{ + "name": "r", + "options": {"password": {"delay": "00:00:01"}}, + "tasks": [{"name": "t", "options": {"password": {"tries": 1}}}] + }] + }); + let before = document.clone(); + Migration::migrate_from( + &Version::new(4, 1, 0), + &PACKAGE_VERSION, + &mut document, + DOCUMENT_MIGRATIONS, + ) + .unwrap(); + assert_eq!(document, before, "the upgrade keeps the option"); + + Migration::migrate_from( + &PACKAGE_VERSION, + &Version::new(4, 1, 0), + &mut document, + DOCUMENT_MIGRATIONS, + ) + .unwrap(); + assert_eq!(document["options"], json!({"timeout": {"type": "tty"}})); + assert_eq!(document["roles"][0]["options"], json!({})); + assert_eq!(document["roles"][0]["tasks"][0]["options"], json!({})); + } + #[test] fn test_4_1_0_documents_up_and_down() { let original = json!({ diff --git a/core/src/database/options.rs b/core/src/database/options.rs index 122c5d60..54b434c7 100644 --- a/core/src/database/options.rs +++ b/core/src/database/options.rs @@ -1,7 +1,7 @@ use core::panic; use std::borrow::Cow; use std::collections::HashMap; -use std::num::ParseIntError; +use std::num::{NonZeroU8, ParseIntError}; use std::result::Result; use std::str::FromStr; use std::{cell::RefCell, rc::Rc}; @@ -22,8 +22,8 @@ use crate::util::{ AUTHENTICATION, BOUNDING, ENV_CHECK_LIST, ENV_DEFAULT_BEHAVIOR, ENV_DELETE_LIST, ENV_KEEP_LIST, ENV_OVERRIDE_BEHAVIOR, ENV_PATH_ADD_LIST_SLICE, ENV_PATH_BEHAVIOR, ENV_PATH_REMOVE_LIST_SLICE, ENV_SET_LIST, HARDENED_ENUM_VALUE_0, HARDENED_ENUM_VALUE_1, HARDENED_ENUM_VALUE_2, - HARDENED_ENUM_VALUE_3, INFO, PRIVILEGED, TIMEOUT_DURATION, TIMEOUT_MAX_USAGE, TIMEOUT_TYPE, - UMASK, is_valid_env_name, is_valid_env_pattern, + HARDENED_ENUM_VALUE_3, INFO, PASSWORD_DELAY, PASSWORD_TRIES, PRIVILEGED, TIMEOUT_DURATION, + TIMEOUT_MAX_USAGE, TIMEOUT_TYPE, UMASK, is_valid_env_name, is_valid_env_pattern, }; use super::{ @@ -108,6 +108,27 @@ pub enum TimestampType { UID, } +/// How many times the password is asked, and how long dosr waits after a wrong one. +/// +/// A level that does not set a field inherits it from the upper levels, down to the build-time +/// defaults (`RAR_PASSWORD_TRIES`, `RAR_PASSWORD_DELAY`). `tries` is at least 1: 0 would never +/// let anyone in, or never stop asking. +#[derive(Serialize, Deserialize, PartialEq, Eq, Debug, Clone, Default, Builder)] +pub struct SPassword { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tries: Option, + #[serde( + default, + serialize_with = "serialize_duration", + deserialize_with = "deserialize_duration", + skip_serializing_if = "Option::is_none" + )] + pub delay: Option, + #[serde(default, flatten, skip_serializing_if = "Map::is_empty")] + #[builder(default)] + pub extra_fields: Map, +} + #[derive(Serialize, Deserialize, PartialEq, Eq, Debug, Clone, Default, Builder)] pub struct STimeout { #[serde(default, rename = "type", skip_serializing_if = "Option::is_none")] @@ -581,6 +602,8 @@ pub struct Opt { #[serde(default, skip_serializing_if = "Option::is_none")] pub timeout: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + pub password: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub umask: Option, #[serde(default, flatten)] pub extra_fields: Map, @@ -599,6 +622,7 @@ impl Opt { execinfo: Option, workdir: Option, timeout: Option, + password: Option, umask: Option, #[builder(default)] extra_fields: Map, ) -> Self { @@ -612,6 +636,7 @@ impl Opt { execinfo, workdir, timeout, + password, umask, extra_fields, } @@ -649,6 +674,12 @@ impl Opt { .maybe_max_usage(TIMEOUT_MAX_USAGE) .build(), ) + .password( + SPassword::builder() + .tries(PASSWORD_TRIES) + .delay(PASSWORD_DELAY) + .build(), + ) .build() } } @@ -1092,6 +1123,26 @@ impl OptStack { .map(|timeout| (opt.level, timeout))) .map(|(_, timeout)| timeout), ) + .password( + SPassword::builder() + .maybe_tries( + self.find_in_options(|opt| opt + .password + .as_ref() + .and_then(|password| password.tries) + .map(|tries| (opt.level, tries))) + .map(|(_, tries)| tries) + ) + .maybe_delay( + self.find_in_options(|opt| opt + .password + .as_ref() + .and_then(|password| password.delay) + .map(|delay| (opt.level, delay))) + .map(|(_, delay)| delay) + ) + .build(), + ) .build() )) } @@ -1873,4 +1924,41 @@ mod tests { // This should serialize as just a string, not as a struct assert_tokens(&umask, &[Token::Str("644")]); } + + #[test] + fn test_password_option() { + let password: SPassword = + serde_json::from_str(r#"{"tries": 2, "delay": "00:00:01"}"#).unwrap(); + assert_eq!(password.tries, std::num::NonZeroU8::new(2)); + assert_eq!(password.delay, Some(SignedDuration::from_secs(1))); + assert_eq!( + serde_json::to_value(&password).unwrap(), + serde_json::json!({"tries": 2, "delay": "00:00:01"}) + ); + // 0 tries would never let anyone in, or never stop asking; a delay is never negative + for invalid in [ + r#"{"tries": 0}"#, + r#"{"tries": 256}"#, + r#"{"tries": -1}"#, + r#"{"delay": "-00:00:01"}"#, + r#"{"delay": "5s"}"#, + ] { + assert!( + serde_json::from_str::(invalid).is_err(), + "{invalid} is refused" + ); + } + let cbor = cbor4ii::serde::to_vec(Vec::new(), &password).unwrap(); + let from_cbor: SPassword = cbor4ii::serde::from_slice(&cbor).unwrap(); + assert_eq!(from_cbor, password); + } + + #[test] + fn test_password_defaults() { + let defaults = Opt::level_default().password.unwrap(); + assert_eq!(defaults.tries, Some(crate::util::PASSWORD_TRIES)); + assert_eq!(defaults.delay, Some(crate::util::PASSWORD_DELAY)); + assert_eq!(crate::util::PASSWORD_TRIES.get(), 3); + assert_eq!(crate::util::PASSWORD_DELAY, SignedDuration::from_secs(5)); + } } diff --git a/core/src/util.rs b/core/src/util.rs index 88dc3c90..e5f93f63 100644 --- a/core/src/util.rs +++ b/core/src/util.rs @@ -20,6 +20,7 @@ use nix::{ unistd::{Gid, Group}, }; use serde::{Deserialize, Serialize}; +use std::num::NonZeroU8; use crate::database::options::{ EnvBehavior, PathBehavior, SAuthentication, SBounding, SInfo, SPrivileged, SUMask, @@ -141,6 +142,26 @@ pub const TIMEOUT_MAX_USAGE: Option = match option_env!("RAR_TIMEOUT_MAX_US None => None, }; +//=== SPassword === + +/// How many times the password is asked before dosr gives up (`RAR_PASSWORD_TRIES`). A value +/// that is not a number from 1 to 255, or a missing build variable, stops the build. +pub const PASSWORD_TRIES: NonZeroU8 = match u8::from_str_radix(env!("RAR_PASSWORD_TRIES"), 10) { + Ok(n) => match NonZeroU8::new(n) { + Some(n) => n, + None => panic!("RAR_PASSWORD_TRIES must be at least 1"), + }, + Err(_) => panic!("RAR_PASSWORD_TRIES must be a number from 1 to 255"), +}; + +/// How long dosr waits after a wrong password (`RAR_PASSWORD_DELAY`, hh:mm:ss). A value that is +/// not a duration, or a missing build variable, stops the build. +pub const PASSWORD_DELAY: SignedDuration = + match convert_string_to_duration(env!("RAR_PASSWORD_DELAY")) { + Ok(delay) => delay, + Err(_) => panic!("RAR_PASSWORD_DELAY must be a duration written hh:mm:ss"), + }; + pub const WORKDIR_BEHAVIOR: WorkdirBehavior = assert_valid_workdir_behavior(WorkdirBehavior::const_parse(env!("RAR_WORKDIR_BEHAVIOR"))); diff --git a/src/dosr/finder/mod.rs b/src/dosr/finder/mod.rs index 488a0d29..bacbafee 100644 --- a/src/dosr/finder/mod.rs +++ b/src/dosr/finder/mod.rs @@ -38,6 +38,8 @@ use cmd::resolve_any_command; pub mod de; mod options; +pub use options::PasswordSettings; + #[derive(Debug, Default, Clone, Builder)] pub struct BestExecSettings { #[builder(default)] @@ -76,6 +78,9 @@ pub struct BestExecSettings { /// Information about whether the user should authenticate or bypass it pub auth: SAuthentication, #[builder(default)] + /// How many times the password is asked, and the delay after a wrong one + pub password: PasswordSettings, + #[builder(default)] /// Is root id has it's privileges or not? If not, root is going to be a simple user pub root: SPrivileged, #[builder(default)] @@ -312,6 +317,7 @@ impl BestExecSettings { result.auth = opt_stack.calc_authentication(); result.bounding = opt_stack.calc_bounding(); result.timeout = opt_stack.calc_timeout(); + result.password = opt_stack.calc_password(); result.root = opt_stack.calc_privileged(); result.umask = opt_stack.calc_umask(); result.workdir = opt_stack.calc_workdir(); diff --git a/src/dosr/finder/options.rs b/src/dosr/finder/options.rs index b81b2f29..9dd3e6e2 100644 --- a/src/dosr/finder/options.rs +++ b/src/dosr/finder/options.rs @@ -7,16 +7,16 @@ use bon::{Builder, bon}; use rootasrole_core::database::FilterMatcher; use rootasrole_core::database::env::{EnvLevel, ResolvedEnv}; use rootasrole_core::database::options::{ - EnvBehavior, Level, PathBehavior, SAuthentication, SBounding, SInfo, SPathOptions, SPrivileged, - STimeout, SUMask, WorkdirBehavior, + EnvBehavior, Level, PathBehavior, SAuthentication, SBounding, SInfo, SPassword, SPathOptions, + SPrivileged, STimeout, SUMask, WorkdirBehavior, }; use rootasrole_core::database::path::{PathLevel, ResolvedPath}; use rootasrole_core::database::score::SecurityMin; use rootasrole_core::database::workdir::{ResolvedWorkdir, WorkdirLevel}; use rootasrole_core::util::{ AUTHENTICATION, BOUNDING, ENV_DEFAULT_BEHAVIOR, ENV_OVERRIDE_BEHAVIOR, ENV_PATH_BEHAVIOR, INFO, - PRIVILEGED, TIMEOUT_DURATION, TIMEOUT_MAX_USAGE, TIMEOUT_TYPE, UMASK, is_valid_env_name, - is_valid_env_pattern, + PASSWORD_DELAY, PASSWORD_TRIES, PRIVILEGED, TIMEOUT_DURATION, TIMEOUT_MAX_USAGE, TIMEOUT_TYPE, + UMASK, is_valid_env_name, is_valid_env_pattern, }; use serde::{Deserialize, Serialize}; @@ -185,6 +185,8 @@ pub struct Opt<'a> { #[serde(default, skip_serializing_if = "Option::is_none")] pub timeout: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + pub password: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub umask: Option, #[serde(default, flatten)] pub extra_fields: Value, @@ -203,6 +205,7 @@ impl<'a> Opt<'a> { execinfo: Option, workdir: Option>, timeout: Option, + password: Option, umask: Option, #[builder(default)] extra_fields: Value, ) -> Self { @@ -216,12 +219,29 @@ impl<'a> Opt<'a> { execinfo, workdir, timeout, + password, umask, extra_fields, } } } +/// How many times dosr asks for the password, and how long it waits after a wrong one. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct PasswordSettings { + pub tries: std::num::NonZeroU8, + pub delay: jiff::SignedDuration, +} + +impl Default for PasswordSettings { + fn default() -> Self { + Self { + tries: PASSWORD_TRIES, + delay: PASSWORD_DELAY, + } + } +} + impl PathLevel for DPathOptions<'_> { fn behavior(&self) -> PathBehavior { self.default_behavior @@ -307,6 +327,7 @@ impl From> for rootasrole_core::database::options::Opt { .maybe_bounding(val.bounding) .maybe_authentication(val.authentication) .maybe_timeout(val.timeout) + .maybe_password(val.password) .build() } } @@ -470,6 +491,20 @@ impl<'c, 'a> BorrowedOptStack<'c, 'a> { extra_fields: Map::new(), }) } + /// The password tries and delay: each one from the most specific level that sets it, or the + /// build-time default. + pub fn calc_password(&self) -> PasswordSettings { + PasswordSettings { + tries: self + .get_opt_iter_rev() + .find_map(|o| o.password.as_ref()?.tries) + .unwrap_or(PASSWORD_TRIES), + delay: self + .get_opt_iter_rev() + .find_map(|o| o.password.as_ref()?.delay) + .unwrap_or(PASSWORD_DELAY), + } + } pub fn calc_info(&self) -> SInfo { self.get_opt_iter_rev() .find_map(|o| o.execinfo) @@ -874,4 +909,45 @@ mod tests { ); assert_eq!(rar_opt.env.unwrap().default_behavior, EnvBehavior::Keep); } + + // each field of the password option comes from the most specific level that sets it + #[test] + fn test_calc_password_inherits_each_field() { + let stack = BorrowedOptStack::new(None); + assert_eq!(stack.calc_password(), PasswordSettings::default()); + + let config = Opt::builder(Level::Global) + .password( + SPassword::builder() + .tries(std::num::NonZeroU8::new(5).unwrap()) + .build(), + ) + .build(); + let mut stack = BorrowedOptStack::new(Some(&config)); + let task = Opt::builder(Level::Task) + .password( + SPassword::builder() + .delay(jiff::SignedDuration::from_secs(1)) + .build(), + ) + .build(); + stack.task = Some(&task); + assert_eq!( + stack.calc_password(), + PasswordSettings { + tries: std::num::NonZeroU8::new(5).unwrap(), + delay: jiff::SignedDuration::from_secs(1), + } + ); + } + + #[test] + fn test_password_option_of_the_policy() { + let opt: Opt = serde_json::from_str(r#"{"password": {"tries": 2, "delay": "00:00:01"}}"#) + .expect("a valid password option"); + let password = opt.password.expect("the password option"); + assert_eq!(password.tries, std::num::NonZeroU8::new(2)); + assert_eq!(password.delay, Some(jiff::SignedDuration::from_secs(1))); + assert!(serde_json::from_str::(r#"{"password": {"tries": 0}}"#).is_err()); + } } diff --git a/src/dosr/lib.rs b/src/dosr/lib.rs index c3585027..501b5e58 100644 --- a/src/dosr/lib.rs +++ b/src/dosr/lib.rs @@ -343,7 +343,13 @@ fn main_inner() -> SrResult<()> { debug!("Best exec settings: {execcfg:?}"); // Let the PAM session live until the end of the program - let session = start_session(execcfg.auth, &execcfg.timeout, &user, &args)?; + let session = start_session( + execcfg.auth, + &execcfg.timeout, + execcfg.password, + &user, + &args, + )?; if !execcfg.score.fully_matching() { println!("You are not allowed to execute this command, this incident will be reported."); diff --git a/src/dosr/pam/mod.rs b/src/dosr/pam/mod.rs index 9395b112..ac7c0ac9 100644 --- a/src/dosr/pam/mod.rs +++ b/src/dosr/pam/mod.rs @@ -14,6 +14,7 @@ use nonstick2::{ TransactionBuilder, conv::Demux, items::ItemsMut, libpam::SessionActive, }; +use crate::finder::PasswordSettings; #[cfg(feature = "timeout")] use crate::timeout; use crate::{ @@ -171,9 +172,40 @@ fn lock_terminal(path: &Path) -> Option { } } +/// Run `attempt` until it succeeds, at most `settings.tries` times. Only a wrong password +/// (`PAM_AUTH_ERR`) is asked again, after `settings.delay`: any other error, such as the end of +/// the input or an unknown user, stops at once. +fn retry_authentication( + mut state: T, + settings: PasswordSettings, + mut attempt: impl FnMut(T) -> Result, +) -> Result { + let tries = settings.tries.get(); + let mut try_number = 1; + loop { + match attempt(state) { + Ok(done) => return Ok(done), + Err((next, error)) => { + warn!("Authentication failed (try {try_number} of {tries}): {error}"); + if error != ErrorCode::AuthenticationError { + return Err(error); + } + std::thread::sleep(settings.delay.unsigned_abs()); + if try_number >= tries { + return Err(error); + } + eprintln!("Sorry, try again."); + try_number += 1; + state = next; + } + } + } +} + pub fn start_session<'a>( authentication: SAuthentication, #[cfg_attr(not(feature = "timeout"), allow(unused_variables))] timeout: &STimeout, + password: PasswordSettings, user: &Cred, cli: &Cli, ) -> SrResult>, SessionActive>> { @@ -219,9 +251,9 @@ pub fn start_session<'a>( })?; let txn = if need_password_auth { - txn.authenticate(AuthnFlags::SILENT) + retry_authentication(txn, password, |txn| txn.authenticate(AuthnFlags::SILENT)) .map_err(|e| { - error!("Authentication failed: {}", e.1); + error!("Authentication failed: {e}"); SrError::AuthenticationFailed })? .account_management(AuthnFlags::SILENT) @@ -419,7 +451,13 @@ mod tests { // When authentication is skipped, it should always succeed let cli = Cli::builder().prompt("Password: ").build(); - let result = start_session(authentication, &timeout, &user, &cli); + let result = start_session( + authentication, + &timeout, + PasswordSettings::default(), + &user, + &cli, + ); assert!(result.is_ok()); } @@ -437,7 +475,13 @@ mod tests { let user = create_test_user(); let cli = Cli::builder().prompt("Password: ").build(); - let _ = start_session(authentication, &timeout, &user, &cli); + let _ = start_session( + authentication, + &timeout, + PasswordSettings::default(), + &user, + &cli, + ); } #[test] @@ -526,8 +570,19 @@ mod tests { let cli = Cli::builder().prompt("Password: ").build(); // Test different timeout types don't cause errors - assert!(start_session(auth, &timeout_ppid, &user, &cli).is_ok()); - assert!(start_session(auth, &timeout_tty, &user, &cli).is_ok()); + assert!( + start_session( + auth, + &timeout_ppid, + PasswordSettings::default(), + &user, + &cli + ) + .is_ok() + ); + assert!( + start_session(auth, &timeout_tty, PasswordSettings::default(), &user, &cli).is_ok() + ); } // Another dosr of the same pipeline opens the terminal on its own: while one holds the lock, @@ -550,4 +605,60 @@ mod tests { fn test_no_terminal_lock_without_a_terminal() { assert!(lock_terminal(Path::new("/nonexistent/tty")).is_none()); } + + fn quick(tries: u8, delay_ms: i64) -> PasswordSettings { + PasswordSettings { + tries: std::num::NonZeroU8::new(tries).unwrap(), + delay: SignedDuration::from_millis(delay_ms), + } + } + + #[test] + fn test_retry_authentication_until_the_password_is_right() { + let mut calls = 0; + let result = retry_authentication((), quick(3, 10), |state| { + calls += 1; + if calls < 2 { + Err((state, ErrorCode::AuthenticationError)) + } else { + Ok("authenticated") + } + }); + assert_eq!(result, Ok("authenticated")); + assert_eq!(calls, 2); + } + + // a delay after each wrong password, the last one included, then dosr gives up + #[test] + fn test_retry_authentication_gives_up_after_the_tries() { + let start = std::time::Instant::now(); + let mut calls = 0; + let result: Result<(), _> = retry_authentication((), quick(3, 50), |state| { + calls += 1; + Err((state, ErrorCode::AuthenticationError)) + }); + assert_eq!(result, Err(ErrorCode::AuthenticationError)); + assert_eq!(calls, 3); + assert!(start.elapsed() >= std::time::Duration::from_millis(150)); + } + + // the end of the input or an unknown user is not a wrong password: no retry, no delay + #[test] + fn test_retry_authentication_stops_on_other_errors() { + for error in [ + ErrorCode::ConversationError, + ErrorCode::UserUnknown, + ErrorCode::MaxTries, + ] { + let start = std::time::Instant::now(); + let mut calls = 0; + let result: Result<(), _> = retry_authentication((), quick(3, 1000), |state| { + calls += 1; + Err((state, error)) + }); + assert_eq!(result, Err(error)); + assert_eq!(calls, 1); + assert!(start.elapsed() < std::time::Duration::from_millis(500)); + } + } } From 4fa0dac683b340541f41ae55df717bcc24b0e9ee Mon Sep 17 00:00:00 2001 From: LeChatP Date: Fri, 23 Oct 2026 14:18:47 +0200 Subject: [PATCH 09/88] fix: dosr refuses to go on when a PAM module changed the user * fix: a PAM module may change PAM_USER during the transaction (pam_get_item(3)), and dosr went on with the session of another user than the one it authenticated; it now checks PAM_USER after the account management, with or without password, and after opening the session, and refuses when it changed or cannot be read * tests: the comparison of the PAM user; the PAM tests of start_session pass the checks Co-Authored-By: Claude --- src/dosr/pam/mod.rs | 34 +++++++++++++++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/src/dosr/pam/mod.rs b/src/dosr/pam/mod.rs index ac7c0ac9..8a13ac3e 100644 --- a/src/dosr/pam/mod.rs +++ b/src/dosr/pam/mod.rs @@ -11,7 +11,7 @@ use bon::Builder; use log::{debug, error, info, warn}; use nonstick2::{ AuthnFlags, ConversationAdapter, ErrorCode, LibPamTransaction, PamShared, Result as PamResult, - TransactionBuilder, conv::Demux, items::ItemsMut, libpam::SessionActive, + TransactionBuilder, conv::Demux, items::Items, items::ItemsMut, libpam::SessionActive, }; use crate::finder::PasswordSettings; @@ -144,6 +144,29 @@ fn set_pam_items(txn: &mut impl PamShared, user: &Cred) -> PamResult<()> { Ok(()) } +/// Whether the user of the PAM transaction is still the one dosr asked for. +fn same_pam_user(pam_user: Option<&std::ffi::OsStr>, requested: &str) -> bool { + pam_user == Some(std::ffi::OsStr::new(requested)) +} + +/// A PAM module may change `PAM_USER` during the transaction (`pam_get_item(3)`): dosr refuses +/// to go on for another user than the one it authenticates. +fn check_pam_user(txn: &impl PamShared, user: &Cred) -> SrResult<()> { + let pam_user = txn.items().user().map_err(|e| { + error!("Cannot read the user of the PAM transaction: {e}"); + SrError::AuthenticationFailed + })?; + if same_pam_user(pam_user.as_deref(), &user.user.name) { + Ok(()) + } else { + error!( + "A PAM module changed the user from {} to {pam_user:?}", + user.user.name + ); + Err(SrError::AuthenticationFailed) + } +} + /// An exclusive lock on the user's terminal, released when dropped (when its file is closed). struct TerminalLock { _terminal: File, @@ -267,6 +290,7 @@ pub fn start_session<'a>( SrError::AuthenticationFailed })? }; + check_pam_user(&txn, user)?; let txn = txn .establish_credentials() @@ -279,6 +303,7 @@ pub fn start_session<'a>( error!("Failed to open session: {}", e.1); SrError::SystemError })?; + check_pam_user(&txn, user)?; // Failing to save the cookie only means that the user will be asked to authenticate again #[cfg(feature = "timeout")] @@ -661,4 +686,11 @@ mod tests { assert!(start.elapsed() < std::time::Duration::from_millis(500)); } } + + #[test] + fn test_same_pam_user() { + assert!(same_pam_user(Some(OsStr::new("alice")), "alice")); + assert!(!same_pam_user(Some(OsStr::new("root")), "alice")); + assert!(!same_pam_user(None, "alice")); + } } From 8600b271b6a0102e411d0a74363a3c7e48193c48 Mon Sep 17 00:00:00 2001 From: LeChatP Date: Fri, 23 Oct 2026 16:16:25 +0200 Subject: [PATCH 10/88] feat: the variables that PAM modules set reach the command, through the env filter of the policy * feat: dosr reads the PAM environment once the session is open (pam_getenvlist(3), set by pam_env or pam_systemd) and adds the variables that the env options of the task keep, as for the variables of the user: keep and check lists in delete mode, delete and check lists in keep mode, never a shell function; pam_env may read a file that the user writes (~/.pam_environment with user_readenv=1) * feat: a variable that the command already has is left as it is: the kept variables of the user, those set by dosr (PATH, HOME, USER, RAR_*, SUDO_*) and the set list take precedence * feat: a PAM variable that is not UTF-8, or has no name, is left out * chore: rootasrole-core gets EnvFilter, the filter of ResolvedEnv owned so that it outlives the policy; both use the same rule * tests: the owned filter decides as the policy's, the precedence, the filter of a task with and without dosr -E, and the PAM environment read from a transaction Co-Authored-By: Claude --- core/src/database/env.rs | 197 +++++++++++++++++++++++++++++++++++---- src/dosr/finder/mod.rs | 74 ++++++++++++++- src/dosr/lib.rs | 8 +- src/dosr/pam/mod.rs | 56 ++++++++++- 4 files changed, 308 insertions(+), 27 deletions(-) diff --git a/core/src/database/env.rs b/core/src/database/env.rs index 22ca8eea..4a2fbfc9 100644 --- a/core/src/database/env.rs +++ b/core/src/database/env.rs @@ -1,10 +1,15 @@ //! How the environment of a command is decided, for dosr and chsr alike. //! //! The env options of the default, global, role and task levels are merged into one set of -//! lists ([`ResolvedEnv`]), then the variables of the user are filtered with them. +//! lists ([`ResolvedEnv`]), then the variables of the user are filtered with them. The same +//! filter ([`EnvFilter`]) applies to the variables that PAM modules set, such as `pam_env`. //! See [`SEnvOptions`](super::options::SEnvOptions) for the meaning of each list. -use std::collections::HashMap; +use std::{ + borrow::Borrow, + collections::{HashMap, hash_map::Entry}, + hash::Hash, +}; use indexmap::{IndexMap, IndexSet}; use libc::PATH_MAX; @@ -112,22 +117,25 @@ impl<'a> ResolvedEnv<'a> { /// - in both modes, a variable of the check list is passed only if its value is safe. #[must_use] pub fn keep_user_var(&self, key: &str, value: &str) -> bool { - if is_shell_function(value) { - return false; - } - let checked = env_matches(&self.check, key); - match self.behavior { - EnvBehavior::Delete => { - if checked { - check_env(key, value) - } else { - env_matches(&self.keep, key) - } - } - EnvBehavior::Keep => { - !env_matches(&self.delete, key) && (!checked || check_env(key, value)) - } - EnvBehavior::Inherit => false, + keeps_var( + self.behavior, + &self.keep, + &self.check, + &self.delete, + key, + value, + ) + } + + /// The same filter as [`ResolvedEnv::keep_user_var`], owned: it outlives the policy. + #[must_use] + pub fn filter(&self) -> EnvFilter { + let owned = |list: &IndexSet<&str>| list.iter().map(|entry| (*entry).to_string()).collect(); + EnvFilter { + behavior: self.behavior, + keep: owned(&self.keep), + check: owned(&self.check), + delete: owned(&self.delete), } } @@ -189,6 +197,83 @@ impl<'a> ResolvedEnv<'a> { } } +/// The filter of the policy on the variables passed to the command, once the policy is gone. +/// +/// dosr decides the environment of the command before the PAM session is open; the variables +/// that PAM modules set then (`pam_env`, `pam_systemd`, `pam_getenvlist(3)`) go through this +/// filter, like the variables of the user: they may come from files that the user writes, such +/// as `~/.pam_environment` with `pam_env user_readenv=1`. The default filter keeps nothing. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct EnvFilter { + behavior: EnvBehavior, + keep: IndexSet, + check: IndexSet, + delete: IndexSet, +} + +impl EnvFilter { + /// Whether a variable is passed to the command, see [`ResolvedEnv::keep_user_var`]. + #[must_use] + pub fn keeps(&self, key: &str, value: &str) -> bool { + keeps_var( + self.behavior, + &self.keep, + &self.check, + &self.delete, + key, + value, + ) + } + + /// Add to the environment of the command the variables that the filter keeps, unless the + /// environment already has them: the kept variables of the user, the variables set by dosr + /// and the `set` list of the policy take precedence. + pub fn add_vars( + &self, + env: &mut HashMap, + vars: impl IntoIterator, + ) { + for (key, value) in vars { + match env.entry(key) { + Entry::Occupied(var) => { + debug!("{} is already set, its other value is left out", var.key()); + } + Entry::Vacant(var) if self.keeps(var.key(), &value) => { + var.insert(value); + } + Entry::Vacant(var) => debug!("{} is left out by the policy", var.key()), + } + } + } +} + +/// The rule of [`ResolvedEnv::keep_user_var`] and [`EnvFilter::keeps`], whether the lists are +/// borrowed from the policy or owned. +fn keeps_var + Hash + Eq>( + behavior: EnvBehavior, + keep: &IndexSet, + check: &IndexSet, + delete: &IndexSet, + key: &str, + value: &str, +) -> bool { + if is_shell_function(value) { + return false; + } + let checked = env_matches(check, key); + match behavior { + EnvBehavior::Delete => { + if checked { + check_env(key, value) + } else { + env_matches(keep, key) + } + } + EnvBehavior::Keep => !env_matches(delete, key) && (!checked || check_env(key, value)), + EnvBehavior::Inherit => false, + } +} + /// Whether the value of a variable of the check list is safe: no `/` nor `%`, except for `TZ` /// which must be a relative time zone name. #[must_use] @@ -201,8 +286,11 @@ pub fn check_env(key: &str, value: &str) -> bool { } /// The policy entries are the patterns, never the user's variable name. -fn env_matches(list: &IndexSet<&str>, name: &str) -> bool { - list.contains(name) || list.iter().any(|pattern| wildcard_match(pattern, name)) +fn env_matches + Hash + Eq>(list: &IndexSet, name: &str) -> bool { + list.contains(name) + || list + .iter() + .any(|pattern| wildcard_match(pattern.borrow(), name)) } fn tz_is_safe(tzval: &str) -> bool { @@ -530,4 +618,73 @@ mod tests { .is_none() ); } + + #[test] + fn test_env_filter_is_the_policy_filter() { + let vars = [ + ("LC_ALL", "C"), + ("KEPT", "value"), + ("LD_PRELOAD", "/tmp/evil.so"), + ("CHECKED", "/unsafe"), + ("CHECKED", "safe"), + ("TZ", "../../etc/x"), + ("TZ", "Europe/Paris"), + ("BASH_FUNC_ls%%", "() { evil; }"), + ("OTHER", "value"), + ]; + for behavior in [EnvBehavior::Delete, EnvBehavior::Keep, EnvBehavior::Inherit] { + let env = resolved(behavior, &["LC_*", "KEPT"], &["CHECKED", "TZ"], &["LD_*"]); + let filter = env.filter(); + for (key, value) in vars { + assert_eq!( + filter.keeps(key, value), + env.keep_user_var(key, value), + "{behavior:?} {key}={value}" + ); + } + } + // a filter that no policy decided keeps nothing + assert!(!EnvFilter::default().keeps("LC_ALL", "C")); + } + + #[test] + fn test_env_filter_add_vars() { + let target = cred(); + let mut env = resolved(EnvBehavior::Delete, &["KEPT", "PAM_*"], &["CHECKED"], &[]); + env.set = IndexMap::from([("SET", "policy")]); + let mut final_env = env + .final_env( + [("KEPT", "user")], + ["/usr/bin"], + &target, + None, + String::new(), + ) + .unwrap(); + let pam_vars = [ + ("PATH", "/tmp/evil"), + ("HOME", "/tmp"), + ("SET", "pam"), + ("KEPT", "pam"), + ("PAM_NEW", "pam"), + ("CHECKED", "/unsafe"), + ("LD_PRELOAD", "/tmp/evil.so"), + ("PAM_FUNC", "() { evil; }"), + ] + .map(|(key, value)| (key.to_string(), value.to_string())); + env.filter().add_vars(&mut final_env, pam_vars); + // the variables of dosr, of the set list and the kept ones of the user take precedence + assert_eq!(final_env.get("PATH").unwrap(), "/usr/bin"); + assert_eq!( + *final_env.get("HOME").unwrap(), + target.user.dir.to_string_lossy() + ); + assert_eq!(final_env.get("SET").unwrap(), "policy"); + assert_eq!(final_env.get("KEPT").unwrap(), "user"); + // a new variable goes through the filter of the policy + assert_eq!(final_env.get("PAM_NEW").unwrap(), "pam"); + assert!(!final_env.contains_key("CHECKED")); + assert!(!final_env.contains_key("LD_PRELOAD")); + assert!(!final_env.contains_key("PAM_FUNC")); + } } diff --git a/src/dosr/finder/mod.rs b/src/dosr/finder/mod.rs index bacbafee..ba923087 100644 --- a/src/dosr/finder/mod.rs +++ b/src/dosr/finder/mod.rs @@ -18,6 +18,7 @@ use rootasrole_core::{ Cred, database::{ actor::{DGroupType, DGroups}, + env::EnvFilter, options::{SAuthentication, SBounding, SPrivileged, STimeout, SUMask}, score::{ActorMatchMin, CmdMin, CmdOrder, HardenedBool, Score, hardened_bool_from_bool}, workdir::ResolvedWorkdir, @@ -64,6 +65,10 @@ pub struct BestExecSettings { /// The final set of environment variable to keep/set pub env: HashMap, #[builder(default)] + /// The filter of the policy on the environment, for the variables that PAM modules set once + /// the session is open + pub env_filter: EnvFilter, + #[builder(default)] /// The PATH variable is managed indepedently given the policy pub env_path: Vec, /// The working directory to set, if specified in policy @@ -293,8 +298,10 @@ impl BestExecSettings { if !matching { return Err(SrError::PermissionDenied); } - result.env = opt_stack - .calc_temp_env(opt_stack.calc_override_behavior(), cli.opt_filter.as_ref()) + let env = + opt_stack.calc_temp_env(opt_stack.calc_override_behavior(), cli.opt_filter.as_ref()); + result.env_filter = env.filter(); + result.env = env .final_env( env_vars, opt_stack.calc_path(env_path), @@ -1889,4 +1896,67 @@ mod tests { ); std::fs::remove_dir_all(&dir).unwrap(); } + + /// The environment that dosr decides for `cli`, completed with variables set by PAM. + fn env_with_pam_vars(cli: &Cli, pam_vars: &[(&str, &str)]) -> HashMap { + use serde::de::DeserializeSeed; + let policy = format!( + r#"{{"roles":[{{"name":"r","actors":[{{"type":"user","id":{}}}],"tasks":[{{"name":"t","commands":["/usr/bin/true"],"options":{{"env":{{"default":"delete","override_behavior":true,"keep":["PAM_*"],"check":["CHECKED"],"delete":["LD_*"],"set":{{"SET":"policy"}}}}}}}}]}}]}}"#, + nix::unistd::getuid() + ); + let cred = dummy_cred(); + let data = super::de::ConfigFinderDeserializer { cli, cred: &cred } + .deserialize(&mut serde_json::Deserializer::from_str(&policy)) + .expect("valid policy"); + let mut settings = BestExecSettings::retrieve_settings( + cli, + &cred, + &data, + [("PAM_USER_VAR", "user")], + &["/usr/bin"], + ) + .unwrap(); + settings.env_filter.add_vars( + &mut settings.env, + pam_vars + .iter() + .map(|(key, value)| ((*key).to_string(), (*value).to_string())), + ); + settings.env + } + + #[test] + fn test_pam_vars_go_through_the_env_filter_of_the_task() { + let pam_vars = [ + ("PAM_NEW", "pam"), + ("PAM_USER_VAR", "pam"), + ("SET", "pam"), + ("PATH", "/tmp/evil"), + ("CHECKED", "/unsafe"), + ("OTHER", "pam"), + ("LD_PRELOAD", "/tmp/evil.so"), + ]; + let cli = Cli::builder().cmd_path("/usr/bin/true".to_string()).build(); + let env = env_with_pam_vars(&cli, &pam_vars); + assert_eq!(env.get("PAM_NEW").unwrap(), "pam"); + assert_eq!(env.get("PAM_USER_VAR").unwrap(), "user"); + assert_eq!(env.get("SET").unwrap(), "policy"); + assert!(!env.get("PATH").unwrap().contains("/tmp/evil")); + assert!(!env.contains_key("CHECKED")); + assert!(!env.contains_key("OTHER")); + assert!(!env.contains_key("LD_PRELOAD")); + // dosr -E, which this task allows: the PAM variables are kept but for the delete list + let cli = Cli::builder() + .cmd_path("/usr/bin/true".to_string()) + .opt_filter( + FilterMatcher::builder() + .env_behavior(EnvBehavior::Keep) + .build(), + ) + .build(); + let env = env_with_pam_vars(&cli, &pam_vars); + assert_eq!(env.get("OTHER").unwrap(), "pam"); + assert!(!env.contains_key("CHECKED")); + assert!(!env.contains_key("LD_PRELOAD")); + } } diff --git a/src/dosr/lib.rs b/src/dosr/lib.rs index 501b5e58..d8fa1d99 100644 --- a/src/dosr/lib.rs +++ b/src/dosr/lib.rs @@ -289,7 +289,7 @@ fn main_inner() -> SrResult<()> { use crate::{ RAR_CFG_PATH, finder::api::{Api, register_plugins}, - pam::start_session, + pam::{session_env, start_session}, }; use finder::find_best_exec_settings; use rootasrole_core::util::RAR_CFG_TYPE; @@ -326,7 +326,7 @@ fn main_inner() -> SrResult<()> { } } register_plugins(); - let execcfg = find_best_exec_settings( + let mut execcfg = find_best_exec_settings( &args, &user, RAR_CFG_PATH, @@ -350,6 +350,10 @@ fn main_inner() -> SrResult<()> { &user, &args, )?; + // The variables that PAM modules set go through the filter of the policy, like the user's + execcfg + .env_filter + .add_vars(&mut execcfg.env, session_env(&session)); if !execcfg.score.fully_matching() { println!("You are not allowed to execute this command, this incident will be reported."); diff --git a/src/dosr/pam/mod.rs b/src/dosr/pam/mod.rs index 8a13ac3e..bd978962 100644 --- a/src/dosr/pam/mod.rs +++ b/src/dosr/pam/mod.rs @@ -10,8 +10,9 @@ use std::{ use bon::Builder; use log::{debug, error, info, warn}; use nonstick2::{ - AuthnFlags, ConversationAdapter, ErrorCode, LibPamTransaction, PamShared, Result as PamResult, - TransactionBuilder, conv::Demux, items::Items, items::ItemsMut, libpam::SessionActive, + AuthnFlags, ConversationAdapter, EnvironMap, ErrorCode, LibPamTransaction, PamShared, + Result as PamResult, TransactionBuilder, conv::Demux, items::Items, items::ItemsMut, + libpam::SessionActive, }; use crate::finder::PasswordSettings; @@ -167,6 +168,22 @@ fn check_pam_user(txn: &impl PamShared, user: &Cred) -> SrResult<()> { } } +/// The variables that PAM modules set for the session (`pam_getenvlist(3)`), such as `pam_env` +/// or `pam_systemd`. The environment of the command is UTF-8: another variable is left out, as +/// a variable without a name. +pub fn session_env(txn: &impl PamShared) -> Vec<(String, String)> { + txn.environ() + .iter() + .filter_map(|(key, value)| match (key.into_string(), value.into_string()) { + (Ok(key), Ok(value)) if !key.is_empty() => Some((key, value)), + (key, _) => { + warn!("A variable of the PAM session is left out, it is not UTF-8 or has no name: {key:?}"); + None + } + }) + .collect() +} + /// An exclusive lock on the user's terminal, released when dropped (when its file is closed). struct TerminalLock { _terminal: File, @@ -319,7 +336,7 @@ mod tests { use super::*; use jiff::SignedDuration; use nix::{libc::dev_t, unistd::Pid}; - use nonstick2::items::Items; + use nonstick2::{EnvironMapMut, items::Items}; use rootasrole_core::{ Cred, database::options::{SAuthentication, STimeout, TimestampType}, @@ -693,4 +710,37 @@ mod tests { assert!(!same_pam_user(Some(OsStr::new("root")), "alice")); assert!(!same_pam_user(None, "alice")); } + + #[test] + fn test_session_env_reads_the_pam_environment() { + let user = create_test_user(); + let conv = SrConversationHandler::builder().no_interact(true).build(); + let Ok(mut txn) = TransactionBuilder::new_with_service(PAM_SERVICE) + .username(&user.user.name) + .build(conv.into_conversation()) + else { + eprintln!("Skipping test, PAM is not available"); + return; + }; + // what a module such as pam_env does (pam_putenv(3)) + { + let mut environ = txn.environ_mut(); + environ.insert("PAM_TEST_VAR", "value=with=equals"); + environ.insert("PAM_TEST_EMPTY", ""); + environ.insert( + OsStr::from_bytes(b"PAM_TEST_\xff"), + "a name that is not UTF-8", + ); + environ.insert("PAM_TEST_BYTES", OsStr::from_bytes(b"\xff")); + } + let env = session_env(&txn); + let pam_test_vars: Vec<_> = env + .iter() + .filter(|(key, _)| key.starts_with("PAM_TEST_")) + .map(|(key, value)| (key.as_str(), value.as_str())) + .collect(); + assert_eq!(pam_test_vars.len(), 2, "{pam_test_vars:?}"); + assert!(pam_test_vars.contains(&("PAM_TEST_VAR", "value=with=equals"))); + assert!(pam_test_vars.contains(&("PAM_TEST_EMPTY", ""))); + } } From f31f6bd411986b33e37202d3eb9cbd713ad3b7b4 Mon Sep 17 00:00:00 2001 From: LeChatP Date: Fri, 23 Oct 2026 16:19:24 +0200 Subject: [PATCH 11/88] tests: end-to-end checks of the PAM environment and of the password retries * tests: the variables that pam_env sets reach the command through the env filter of the task, and never replace PATH nor a variable of the user that the task keeps; pam_env is added to the PAM service of dosr for the check, then the service is restored, since Debian's common-* files do not run it * tests: with -S, three wrong passwords are refused, two wrong ones then the right one are accepted after two "Sorry, try again."; the timestamp cookie and the pam_faillock counter (Arch) are cleared around the check Co-Authored-By: Claude --- tests/vagrant/e2e.sh | 75 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/tests/vagrant/e2e.sh b/tests/vagrant/e2e.sh index 03903b66..7f5ddc14 100755 --- a/tests/vagrant/e2e.sh +++ b/tests/vagrant/e2e.sh @@ -9,7 +9,11 @@ POLICY_DIR=/etc/security/rootasrole.d E2E_POLICY="$POLICY_DIR/e2e.json" PLANTED_POLICY="$POLICY_DIR/planted.json" TEST_USER=rar-e2e +TEST_PASSWORD=rar-e2e-password UNIT=rar-e2e +PAM_SERVICE=/etc/pam.d/dosr +PAM_SERVICE_BACKUP=/etc/pam.d/.dosr.rar-e2e +PAM_ENV_FILE=/etc/rar-e2e.env FAILED=0 # 0: pass, 77: skip, anything else: fail @@ -92,6 +96,21 @@ setup() { "name": "net-raw", "cred": { "capabilities": ["CAP_NET_RAW"] }, "commands": ["/usr/bin/true"] + }, + { + "name": "env", + "options": { + "env": { "default": "delete", "keep": ["RAR_E2E_KEPT"], "check": ["RAR_E2E_CHECKED"] } + }, + "commands": ["/usr/bin/env"] + }, + { + "name": "password", + "options": { + "authentication": "perform", + "password": { "tries": 3, "delay": "00:00:01" } + }, + "commands": ["/usr/bin/true"] } ] }] @@ -100,7 +119,16 @@ EOF )" } +# the PAM service of dosr as installed, without the line added by pam_env_is_filtered +restore_pam_service() { + if [ -e "$PAM_SERVICE_BACKUP" ]; then + mv -f "$PAM_SERVICE_BACKUP" "$PAM_SERVICE" + fi + rm -f "$PAM_ENV_FILE" +} + cleanup() { + restore_pam_service systemctl stop "$UNIT" 2>/dev/null rm -f "/etc/systemd/system/$UNIT.service" systemctl daemon-reload @@ -184,6 +212,51 @@ bounding_set_is_checked_first() { [ $? -ne 0 ] && grep -q 'cannot obtain' <<<"$out" } +# the variables that PAM modules set reach the command through the env filter of the task, and +# never replace the variables of dosr nor the ones of the user that the task keeps; pam_env is +# added for this check, since the PAM service of Debian does not run it +pam_env_is_filtered() { + local out user_out + cp -p "$PAM_SERVICE" "$PAM_SERVICE_BACKUP" || return 1 + printf '%s\n' RAR_E2E_KEPT=pam RAR_E2E_DROPPED=pam RAR_E2E_CHECKED=/unsafe PATH=/tmp/rar-e2e \ + >"$PAM_ENV_FILE" + echo "session optional pam_env.so readenv=1 envfile=$PAM_ENV_FILE user_readenv=0" \ + >>"$PAM_SERVICE" + out=$(as_user dosr -r e2e -t env /usr/bin/env) + user_out=$(as_user env RAR_E2E_KEPT=user dosr -r e2e -t env /usr/bin/env) + restore_pam_service + grep -qx RAR_E2E_KEPT=pam <<<"$out" && + ! grep -q '^RAR_E2E_DROPPED=' <<<"$out" && + ! grep -q '^RAR_E2E_CHECKED=' <<<"$out" && + ! grep -qx PATH=/tmp/rar-e2e <<<"$out" && + grep -qx RAR_E2E_KEPT=user <<<"$user_out" +} + +# a fresh authentication: no timestamp cookie, no failure counted by pam_faillock (Arch) +forget_authentications() { + as_user dosr -K >/dev/null 2>&1 + if command -v faillock >/dev/null; then + faillock --user "$TEST_USER" --reset + fi +} + +# a wrong password is asked again, up to the tries of the policy; the cookie of a successful +# authentication would skip the password, so the refusal is checked first +password_is_asked_again() { + local out + echo "$TEST_USER:$TEST_PASSWORD" | chpasswd || return 1 + forget_authentications + if printf '%s\n' wrong wrong wrong "$TEST_PASSWORD" | + as_user dosr -S -r e2e -t password /usr/bin/true >/dev/null 2>&1; then + return 1 + fi + forget_authentications + out=$(printf '%s\n' wrong wrong "$TEST_PASSWORD" | + as_user dosr -S -r e2e -t password /usr/bin/true 2>&1) || return 1 + forget_authentications + [ "$(grep -c 'Sorry, try again.' <<<"$out")" -eq 2 ] +} + trap cleanup EXIT setup check "the policy, its directory included, is immutable" policy_is_immutable @@ -194,4 +267,6 @@ check "a command written without arguments allows none" command_without_argument check "uid 0 without capabilities manages a unit" root_without_capabilities_manages_a_unit check "CAP_NET_BIND_SERVICE is granted" capability_is_granted check "the bounding set is checked before running the command" bounding_set_is_checked_first +check "the variables of pam_env go through the env filter of the task" pam_env_is_filtered +check "a wrong password is asked again, up to the tries of the policy" password_is_asked_again exit "$FAILED" From 82c3191869e974bf8765f5e76261817fb5c9a9ca Mon Sep 17 00:00:00 2001 From: LeChatP Date: Fri, 23 Oct 2026 16:24:44 +0200 Subject: [PATCH 12/88] fix: a PPID timestamp cookie is bound to the session of dosr too * fix: with the ppid scope (and the tty scope without a terminal), a cookie served any process of the same parent, even in another session after setsid(2); the record now keeps the session id, like sudo; only the id, since the session leader may be gone while the session lives on * fix: the cookies of 4.1.0 have no session and are no longer reused: the password is asked once after the upgrade; after a downgrade, 4.1.0 reads the new cookies and ignores the session * tests: a cookie from another session is refused, the record holds the session of dosr, a 4.1.0 cookie is not reused, 4.1.0 reads a new cookie Co-Authored-By: Claude --- src/dosr/timeout.rs | 131 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 131 insertions(+) diff --git a/src/dosr/timeout.rs b/src/dosr/timeout.rs index bd14811c..befc1744 100644 --- a/src/dosr/timeout.rs +++ b/src/dosr/timeout.rs @@ -49,6 +49,10 @@ enum ParentRecord { Ppid { ppid: pid_t, ppid_start: u64, + /// The session of dosr: a child of the same parent in another session (after + /// `setsid(2)`) does not reuse the cookie. Only the id is kept: the session leader may be + /// gone while the session lives on, without a start time to read. + session: pid_t, }, None, } @@ -69,6 +73,7 @@ impl ParentRecord { (TimestampType::TTY | TimestampType::PPID, _) => Some(Self::Ppid { ppid: user.ppid.as_raw(), ppid_start: procfs::start_time(user.ppid).ok()?, + session: getsid(None).ok()?.as_raw(), }), (TimestampType::UID, _) => Some(Self::None), } @@ -562,6 +567,128 @@ mod test { })); } + #[test] + fn test_cookie_requires_matching_session() { + let _guard = MUTEX.lock().unwrap(); + setup(); + // same parent process, but dosr runs in another session + assert!(!is_valid_after(|cookie| { + if let ParentRecord::Ppid { session, .. } = &mut cookie.parent_record { + *session += 1; + } + })); + } + + /// The cookie types of 4.1.0, whose PPID scope has no session. + mod v4_1_0 { + use serde::{Deserialize, Serialize}; + + #[derive(Serialize, Deserialize, Debug)] + pub enum CookieVersion { + V2(Cookiev2), + } + + #[derive(Serialize, Deserialize, Debug, PartialEq, Eq)] + #[serde(rename_all = "UPPERCASE")] + pub enum ParentRecord { + Tty { + tty: u64, + session: i32, + session_start: u64, + }, + Ppid { + ppid: i32, + ppid_start: u64, + }, + None, + } + + #[derive(Serialize, Deserialize, Debug)] + pub struct Cookiev2 { + pub timestamp_type: rootasrole_core::database::options::TimestampType, + pub start_time: i64, + pub timestamp: i64, + pub usage: u64, + pub parent_record: ParentRecord, + pub auth_uid: u32, + } + } + + fn ppid_constraint() -> STimeout { + STimeout { + type_field: Some(TimestampType::PPID), + duration: Some(SignedDuration::from_secs(10)), + max_usage: None, + extra_fields: Map::default(), + } + } + + fn root_cred() -> Cred { + Cred { + user: User::from_uid(0.into()).unwrap().unwrap(), + curdir: "".into(), + groups: vec![], + tty: None, + ppid: Pid::parent(), + } + } + + #[test] + fn test_cookie_of_4_1_0_is_not_reused() { + let _guard = MUTEX.lock().unwrap(); + setup(); + let cred = root_cred(); + let constraint = ppid_constraint(); + clear_cookies(&cred).unwrap(); + let old = v4_1_0::CookieVersion::V2(v4_1_0::Cookiev2 { + timestamp_type: TimestampType::PPID, + start_time: boottime().unwrap(), + timestamp: boottime().unwrap(), + usage: 0, + parent_record: v4_1_0::ParentRecord::Ppid { + ppid: cred.ppid.as_raw(), + ppid_start: procfs::start_time(cred.ppid).unwrap(), + }, + auth_uid: 0, + }); + { + let mut file = CookieFile::open(&cred).unwrap(); + file.0.set_len(0).unwrap(); + cbor4ii::serde::to_writer(&mut *file.0, &[old]).unwrap(); + } + // a cookie without the session asks for the password again, then a new one is saved + assert!(!is_valid(&cred, &cred, &constraint)); + update_cookie(&cred, &cred, &constraint).unwrap(); + assert!(is_valid(&cred, &cred, &constraint)); + clear_cookies(&cred).unwrap(); + } + + #[test] + fn test_cookie_is_read_by_4_1_0() { + let _guard = MUTEX.lock().unwrap(); + setup(); + let cred = root_cred(); + let constraint = ppid_constraint(); + clear_cookies(&cred).unwrap(); + update_cookie(&cred, &cred, &constraint).unwrap(); + let cookies: Vec = { + let file = CookieFile::open(&cred).unwrap(); + cbor4ii::serde::from_reader(BufReader::new(&*file.0)).unwrap() + }; + clear_cookies(&cred).unwrap(); + // after a downgrade, 4.1.0 ignores the session and keeps its own scope + let [v4_1_0::CookieVersion::V2(cookie)] = cookies.as_slice() else { + panic!("one cookie expected: {cookies:?}"); + }; + assert_eq!( + cookie.parent_record, + v4_1_0::ParentRecord::Ppid { + ppid: cred.ppid.as_raw(), + ppid_start: procfs::start_time(cred.ppid).unwrap(), + } + ); + } + #[test] fn test_cookie_from_the_future_is_rejected() { let _guard = MUTEX.lock().unwrap(); @@ -581,6 +708,10 @@ mod test { ppid: Pid::parent(), }; let ppid_record = ParentRecord::new(TimestampType::PPID, &cred).unwrap(); + assert!(matches!( + ppid_record, + ParentRecord::Ppid { session, .. } if session == getsid(None).unwrap().as_raw() + )); assert_eq!( ParentRecord::new(TimestampType::TTY, &cred).unwrap(), ppid_record From 682633181729304fcc89fc9c5350cb3c10781ddb Mon Sep 17 00:00:00 2001 From: LeChatP Date: Fri, 23 Oct 2026 16:30:50 +0200 Subject: [PATCH 13/88] fix: dosr and chsr no longer panic when their output is a closed pipe * fix: Rust ignores SIGPIPE, so a write to a pipe that nobody reads (dosr --help | true, dosr -i | head) fails with EPIPE, and println! and eprintln! panic on it; dosr ended with exit code 101, and a message of a PAM module printed from the conversation, a callback of libpam, would abort the process * fix: rootasrole-core exports println_ignore_io_error!, print_ignore_io_error! and eprintln_ignore_io_error!, which ignore write errors; dosr and chsr use them outside the tests * chore: outside the tests, the crate roots of rootasrole-core, dosr and chsr shadow println!, print!, eprintln! and eprint! with a compile_error!, so that a new use does not build * tests: dosr --help and --version with stdout on a closed pipe exit 0 without a panic Co-Authored-By: Claude --- core/src/lib.rs | 59 ++++++++++++++++++++++++++++ src/chsr/cli/process/convert.rs | 3 +- src/chsr/cli/process/json.rs | 69 ++++++++++++++++++++++----------- src/chsr/cli/process/upgrade.rs | 5 ++- src/chsr/cli/usage.rs | 11 +++--- src/chsr/main.rs | 31 +++++++++++++++ src/dosr/lib.rs | 68 ++++++++++++++++++++++++-------- src/dosr/pam/mod.rs | 7 ++-- tests/integration_tests.rs | 18 +++++++++ 9 files changed, 221 insertions(+), 50 deletions(-) diff --git a/core/src/lib.rs b/core/src/lib.rs index 7d4977b3..d69b6a23 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -61,6 +61,65 @@ use libc::dev_t; use nix::unistd::{Gid, Group, Pid, Uid, User, getgroups}; use serde::{Deserialize, Serialize}; +/// [`println!`] that ignores write errors: a privileged program must not panic because its +/// output is a closed pipe (`dosr -i | true` gets `EPIPE`, since Rust ignores `SIGPIPE`). +#[macro_export] +macro_rules! println_ignore_io_error { + ($($tt:tt)*) => {{ + use ::std::io::Write as _; + let _ = ::std::writeln!(::std::io::stdout(), $($tt)*); + }}; +} + +/// [`print!`] that ignores write errors, see [`println_ignore_io_error`]. +#[macro_export] +macro_rules! print_ignore_io_error { + ($($tt:tt)*) => {{ + use ::std::io::Write as _; + let _ = ::std::write!(::std::io::stdout(), $($tt)*); + }}; +} + +/// [`eprintln!`] that ignores write errors, see [`println_ignore_io_error`]. +#[macro_export] +macro_rules! eprintln_ignore_io_error { + ($($tt:tt)*) => {{ + use ::std::io::Write as _; + let _ = ::std::writeln!(::std::io::stderr(), $($tt)*); + }}; +} + +// `println!` and the like panic when the output is a closed pipe (`EPIPE`, since Rust ignores +// `SIGPIPE`): outside the tests, the `*_ignore_io_error` macros of rootasrole-core are used. +#[cfg(not(test))] +#[allow(unused_macros)] +macro_rules! println { + ($($tt:tt)*) => { + compile_error!("println! panics on EPIPE, use println_ignore_io_error!") + }; +} +#[cfg(not(test))] +#[allow(unused_macros)] +macro_rules! print { + ($($tt:tt)*) => { + compile_error!("print! panics on EPIPE, use print_ignore_io_error!") + }; +} +#[cfg(not(test))] +#[allow(unused_macros)] +macro_rules! eprintln { + ($($tt:tt)*) => { + compile_error!("eprintln! panics on EPIPE, use eprintln_ignore_io_error!") + }; +} +#[cfg(not(test))] +#[allow(unused_macros)] +macro_rules! eprint { + ($($tt:tt)*) => { + compile_error!("eprint! panics on EPIPE, use eprintln_ignore_io_error!") + }; +} + //pub mod api; pub mod database; //pub mod plugin; diff --git a/src/chsr/cli/process/convert.rs b/src/chsr/cli/process/convert.rs index 49518534..ab2b849a 100644 --- a/src/chsr/cli/process/convert.rs +++ b/src/chsr/cli/process/convert.rs @@ -1,3 +1,4 @@ +use rootasrole_core::println_ignore_io_error; use std::{ error::Error, path::{Path, PathBuf}, @@ -49,7 +50,7 @@ pub fn convert( } None => settings.get_root().config.clone(), }; - println!( + println_ignore_io_error!( "Config : {}", serde_json::to_string_pretty(&Versioning::new(config.clone()))? ); diff --git a/src/chsr/cli/process/json.rs b/src/chsr/cli/process/json.rs index 447d69ac..9928624d 100644 --- a/src/chsr/cli/process/json.rs +++ b/src/chsr/cli/process/json.rs @@ -1,3 +1,4 @@ +use rootasrole_core::println_ignore_io_error; use std::{cell::RefCell, collections::HashMap, error::Error, rc::Rc}; use indexmap::IndexSet; @@ -30,7 +31,7 @@ pub fn list_json( let config = rconfig.as_ref().borrow(); role_id.map_or_else( || { - println!( + println_ignore_io_error!( "{}", serde_json::to_string_pretty(&*config) .unwrap_or_else(|_| "Failed to serialize config".to_string()) @@ -65,35 +66,59 @@ fn list_task( if let Some(opttype) = options_type { match opttype { OptType::Env => { - println!("{}", serde_json::to_string_pretty(&opt.env)?); + println_ignore_io_error!("{}", serde_json::to_string_pretty(&opt.env)?); } OptType::Path => { - println!("{}", serde_json::to_string_pretty(&opt.path)?); + println_ignore_io_error!( + "{}", + serde_json::to_string_pretty(&opt.path)? + ); } OptType::Root => { - println!("{}", serde_json::to_string_pretty(&opt.root)?); + println_ignore_io_error!( + "{}", + serde_json::to_string_pretty(&opt.root)? + ); } OptType::Bounding => { - println!("{}", serde_json::to_string_pretty(&opt.bounding)?); + println_ignore_io_error!( + "{}", + serde_json::to_string_pretty(&opt.bounding)? + ); } OptType::Timeout => { - println!("{}", serde_json::to_string_pretty(&opt.timeout)?); + println_ignore_io_error!( + "{}", + serde_json::to_string_pretty(&opt.timeout)? + ); } OptType::Authentication => { - println!("{}", serde_json::to_string_pretty(&opt.authentication)?); + println_ignore_io_error!( + "{}", + serde_json::to_string_pretty(&opt.authentication)? + ); } OptType::ExecInfo => { - println!("{}", serde_json::to_string_pretty(&opt.execinfo)?); + println_ignore_io_error!( + "{}", + serde_json::to_string_pretty(&opt.execinfo)? + ); } OptType::UMask => { - println!("{}", serde_json::to_string_pretty(&opt.umask)?); + println_ignore_io_error!( + "{}", + serde_json::to_string_pretty(&opt.umask)? + ); } OptType::Workdir => { - println!("{}", serde_json::to_string_pretty(&opt.workdir)?); + println_ignore_io_error!( + "{}", + serde_json::to_string_pretty(&opt.workdir)? + ); } } } else { - println!("{}", serde_json::to_string_pretty(&rcopt)?); + println_ignore_io_error!("{}", serde_json::to_string_pretty(&rcopt)?); } } else { print_task(task, task_type.unwrap_or(TaskType::All))?; @@ -105,7 +130,7 @@ fn list_task( let rcopt = OptStack::from_role(&role.clone()) .to_opt() .map_err(std::io::Error::other)?; - println!("{}", serde_json::to_string_pretty(&rcopt)?); + println_ignore_io_error!("{}", serde_json::to_string_pretty(&rcopt)?); } else { print_role(role, role_type.unwrap_or(RoleType::All))?; } @@ -118,16 +143,16 @@ fn print_task( ) -> Result<(), Box> { match task_type { TaskType::All => { - println!("{}", serde_json::to_string_pretty(&task)?); + println_ignore_io_error!("{}", serde_json::to_string_pretty(&task)?); } TaskType::Commands => { - println!( + println_ignore_io_error!( "{}", serde_json::to_string_pretty(&task.as_ref().borrow().commands)? ); } TaskType::Credentials => { - println!( + println_ignore_io_error!( "{}", serde_json::to_string_pretty(&task.as_ref().borrow().cred)? ); @@ -142,16 +167,16 @@ fn print_role( ) -> Result<(), Box> { match role_type { RoleType::All => { - println!("{}", serde_json::to_string_pretty(&role)?); + println_ignore_io_error!("{}", serde_json::to_string_pretty(&role)?); } RoleType::Actors => { - println!( + println_ignore_io_error!( "{}", serde_json::to_string_pretty(&role.as_ref().borrow().actors)? ); } RoleType::Tasks => { - println!( + println_ignore_io_error!( "{}", serde_json::to_string_pretty(&role.as_ref().borrow().tasks)? ); @@ -300,7 +325,7 @@ pub fn grant_revoke( //remove already existing actors actors.retain(|a| { if role.as_ref().borrow().actors.contains(a) { - println!("Actor {a} already in role"); + println_ignore_io_error!("Actor {a} already in role"); false } else { true @@ -312,7 +337,7 @@ pub fn grant_revoke( InputAction::Del => { //if actor is not in role, warns if !role.as_ref().borrow().actors.contains(&actors[0]) { - println!("Actor {} not in role", actors[0]); + println_ignore_io_error!("Actor {} not in role", actors[0]); } role.as_ref() .borrow_mut() @@ -500,7 +525,7 @@ pub fn cmd_whitelist_action( InputAction::Del => { //if command is not in task, warns if !task.as_ref().borrow().commands.add.contains(&cmd) { - println!("Command {cmd:?} not in task"); + println_ignore_io_error!("Command {cmd:?} not in task"); } task.as_ref().borrow_mut().commands.add.retain(|c| { debug!("'{:?}' != '{:?}' : {}", c, &cmd, *c != cmd); @@ -520,7 +545,7 @@ pub fn cmd_whitelist_action( InputAction::Del => { //if command is not in task, warns if !task.as_ref().borrow().commands.sub.contains(&cmd) { - println!("Command {cmd:?} not in task"); + println_ignore_io_error!("Command {cmd:?} not in task"); } task.as_ref() .borrow_mut() diff --git a/src/chsr/cli/process/upgrade.rs b/src/chsr/cli/process/upgrade.rs index 2fee8e1a..3b58c34a 100644 --- a/src/chsr/cli/process/upgrade.rs +++ b/src/chsr/cli/process/upgrade.rs @@ -1,3 +1,4 @@ +use rootasrole_core::{eprintln_ignore_io_error, println_ignore_io_error}; use std::error::Error; use log::debug; @@ -65,12 +66,12 @@ pub fn upgrade(settings: &mut FileSettings, check: bool) -> Result String { pub fn help() { debug!("chsr help"); - println!("{LONG_ABOUT}"); - println!( + println_ignore_io_error!("{LONG_ABOUT}"); + println_ignore_io_error!( "{}", render_usage(&[UsageSectionId::Usage, UsageSectionId::Commands]) ); @@ -701,7 +702,7 @@ fn rule_to_string(rule: Rule) -> String { Rule::del => "del", Rule::purge => "purge", _ => { - println!("{rule:?}"); + println_ignore_io_error!("{rule:?}"); "unknown rule" } } @@ -792,8 +793,8 @@ pub fn print_usage(e: pest::error::Error) -> Result> } rule_to_string(*rule) }); - println!("{usage}"); - println!( + println_ignore_io_error!("{usage}"); + println_ignore_io_error!( "{RED}{BOLD}Unrecognized command line:\n| {RST}{}{RED}{BOLD}\n| {}\n= {}{RST}", e.line(), underline(&e), diff --git a/src/chsr/main.rs b/src/chsr/main.rs index 439bbdab..e832d236 100644 --- a/src/chsr/main.rs +++ b/src/chsr/main.rs @@ -1,5 +1,36 @@ //extern crate sudoers_reader; +// `println!` and the like panic when the output is a closed pipe (`EPIPE`, since Rust ignores +// `SIGPIPE`): outside the tests, the `*_ignore_io_error` macros of rootasrole-core are used. +#[cfg(not(test))] +#[allow(unused_macros)] +macro_rules! println { + ($($tt:tt)*) => { + compile_error!("println! panics on EPIPE, use println_ignore_io_error!") + }; +} +#[cfg(not(test))] +#[allow(unused_macros)] +macro_rules! print { + ($($tt:tt)*) => { + compile_error!("print! panics on EPIPE, use print_ignore_io_error!") + }; +} +#[cfg(not(test))] +#[allow(unused_macros)] +macro_rules! eprintln { + ($($tt:tt)*) => { + compile_error!("eprintln! panics on EPIPE, use eprintln_ignore_io_error!") + }; +} +#[cfg(not(test))] +#[allow(unused_macros)] +macro_rules! eprint { + ($($tt:tt)*) => { + compile_error!("eprint! panics on EPIPE, use eprintln_ignore_io_error!") + }; +} + mod cli; #[cfg(not(tarpaulin_include))] mod security; diff --git a/src/dosr/lib.rs b/src/dosr/lib.rs index d8fa1d99..c3615ef3 100644 --- a/src/dosr/lib.rs +++ b/src/dosr/lib.rs @@ -2,6 +2,37 @@ //! It is a library so that its internals can be fuzzed in-process (see the `fuzzing` feature). #![cfg(feature = "finder")] +// `println!` and the like panic when the output is a closed pipe (`EPIPE`, since Rust ignores +// `SIGPIPE`): outside the tests, the `*_ignore_io_error` macros of rootasrole-core are used. +#[cfg(not(test))] +#[allow(unused_macros)] +macro_rules! println { + ($($tt:tt)*) => { + compile_error!("println! panics on EPIPE, use println_ignore_io_error!") + }; +} +#[cfg(not(test))] +#[allow(unused_macros)] +macro_rules! print { + ($($tt:tt)*) => { + compile_error!("print! panics on EPIPE, use print_ignore_io_error!") + }; +} +#[cfg(not(test))] +#[allow(unused_macros)] +macro_rules! eprintln { + ($($tt:tt)*) => { + compile_error!("eprintln! panics on EPIPE, use eprintln_ignore_io_error!") + }; +} +#[cfg(not(test))] +#[allow(unused_macros)] +macro_rules! eprint { + ($($tt:tt)*) => { + compile_error!("eprint! panics on EPIPE, use eprintln_ignore_io_error!") + }; +} + mod error; mod finder; #[cfg(feature = "fuzzing")] @@ -31,6 +62,7 @@ use rootasrole_core::{ use log::{debug, error, info}; use pam::PAM_PROMPT; +use rootasrole_core::{eprintln_ignore_io_error, print_ignore_io_error, println_ignore_io_error}; use std::ffi::{CString, OsString}; use std::os::unix::ffi::OsStringExt; use std::path::PathBuf; @@ -210,7 +242,7 @@ where args.help = true; } "-v" | "--version" => { - println!("dosr: version {}", env!("CARGO_PKG_VERSION")); + println_ignore_io_error!("dosr: version {}", env!("CARGO_PKG_VERSION")); std::process::exit(0); } _ => { @@ -251,11 +283,11 @@ where #[cfg(not(tarpaulin_include))] pub fn dosr_main() { if let Err(e) = subsribe("sr") { - eprintln!("dosr: Failed to initialize logging: {e}"); + eprintln_ignore_io_error!("dosr: Failed to initialize logging: {e}"); std::process::exit(1); } if let Err(e) = main_inner() { - eprintln!("dosr: {e}"); + eprintln_ignore_io_error!("dosr: {e}"); if e == SrError::InsufficientPrivileges { error!("Insufficient privileges to run sr. {CAPABILITIES_ERROR}"); } else if e == SrError::AuthenticationFailed { @@ -266,7 +298,7 @@ pub fn dosr_main() { .unwrap_or_else(|_| Uid::current().to_string()), command_line() ); - eprintln!("This incident is reported."); + eprintln_ignore_io_error!("This incident is reported."); } else { error!( "User '{}' got a '{}' when trying running '''{}'''", @@ -298,13 +330,13 @@ fn main_inner() -> SrResult<()> { drop_effective()?; let args = utf8_args(std::env::args_os())?; if args.len() < 2 { - println!("{USAGE}"); + println_ignore_io_error!("{USAGE}"); return Ok(()); } let args = getopt(args)?; if args.help { - println!("{USAGE}"); + println_ignore_io_error!("{USAGE}"); return Ok(()); } let user = make_cred().map_err(|_| SrError::SystemError)?; @@ -356,7 +388,9 @@ fn main_inner() -> SrResult<()> { .add_vars(&mut execcfg.env, session_env(&session)); if !execcfg.score.fully_matching() { - println!("You are not allowed to execute this command, this incident will be reported."); + println_ignore_io_error!( + "You are not allowed to execute this command, this incident will be reported." + ); error!( "User {} tried to execute command : {} {:?} without the permission.", &user.user.name, @@ -376,7 +410,7 @@ fn main_inner() -> SrResult<()> { .map(|cap| cap.to_string()) .collect::>() .join(" "); - eprintln!( + eprintln_ignore_io_error!( "dosr: the task needs capabilities that this session cannot obtain anymore: {names}" ); error!( @@ -391,7 +425,7 @@ fn main_inner() -> SrResult<()> { if args.info { use capctl::CapSet; - println!( + println_ignore_io_error!( "Role: {}", if execcfg.role.is_empty() { "None" @@ -399,8 +433,8 @@ fn main_inner() -> SrResult<()> { &execcfg.role } ); - println!("Task: {}", execcfg.task.as_ref().map_or("None", |v| v)); - print!( + println_ignore_io_error!("Task: {}", execcfg.task.as_ref().map_or("None", |v| v)); + print_ignore_io_error!( "Execute as user: {}", if let Some(u) = execcfg.cred.setuid { format!("{} ({})", u.name, u.uid) @@ -409,17 +443,17 @@ fn main_inner() -> SrResult<()> { } ); if let Some(gids) = execcfg.cred.setgroups.as_ref() { - print!(" and group(s): "); + print_ignore_io_error!(" and group(s): "); let groups = gids .iter() .map(|g| format!("{} ({})", g.name, g.gid)) .collect::>() .join(", "); - println!("{groups}"); + println_ignore_io_error!("{groups}"); } else { - println!(" your current group(s)"); + println_ignore_io_error!(" your current group(s)"); } - println!( + println_ignore_io_error!( "With capabilities: {}", execcfg.cred.caps.map_or_else( || "None".to_string(), @@ -433,7 +467,7 @@ fn main_inner() -> SrResult<()> { } ) ); - println!( + println_ignore_io_error!( "Command: {} {:?}", execcfg.final_path.display(), args.cmd_args @@ -491,7 +525,7 @@ fn main_inner() -> SrResult<()> { Ok(status) => status, Err(e) => { error!("{e}"); - eprintln!("dosr: {} : {}", cfinal_path.display(), e); + eprintln_ignore_io_error!("dosr: {} : {}", cfinal_path.display(), e); std::process::exit(1); } }; diff --git a/src/dosr/pam/mod.rs b/src/dosr/pam/mod.rs index bd978962..b4a5adcb 100644 --- a/src/dosr/pam/mod.rs +++ b/src/dosr/pam/mod.rs @@ -14,6 +14,7 @@ use nonstick2::{ Result as PamResult, TransactionBuilder, conv::Demux, items::Items, items::ItemsMut, libpam::SessionActive, }; +use rootasrole_core::{eprintln_ignore_io_error, println_ignore_io_error}; use crate::finder::PasswordSettings; #[cfg(feature = "timeout")] @@ -124,12 +125,12 @@ impl ConversationAdapter for SrConversationHandler<'_> { fn error_msg(&self, message: impl AsRef) { error!("{}", message.as_ref().to_string_lossy()); - eprintln!("{}", message.as_ref().to_string_lossy()); + eprintln_ignore_io_error!("{}", message.as_ref().to_string_lossy()); } fn info_msg(&self, message: impl AsRef) { info!("{}", message.as_ref().to_string_lossy()); - println!("{}", message.as_ref().to_string_lossy()); + println_ignore_io_error!("{}", message.as_ref().to_string_lossy()); } } @@ -234,7 +235,7 @@ fn retry_authentication( if try_number >= tries { return Err(error); } - eprintln!("Sorry, try again."); + eprintln_ignore_io_error!("Sorry, try again."); try_number += 1; state = next; } diff --git a/tests/integration_tests.rs b/tests/integration_tests.rs index 450bb683..75530b00 100644 --- a/tests/integration_tests.rs +++ b/tests/integration_tests.rs @@ -44,6 +44,24 @@ mod tests { assert_eq!(result.exit_code, 0); } + /// Nobody reads the output anymore (`dosr --help | true`): writing gets EPIPE, since Rust + /// ignores SIGPIPE, and dosr must not panic. + #[test] + fn test_dosr_output_to_a_closed_pipe() { + let (reader, writer) = std::io::pipe().expect("Failed to create a pipe"); + drop(reader); + for arg in ["--help", "--version"] { + let output = std::process::Command::new(env!("CARGO_BIN_EXE_dosr")) + .arg(arg) + .stdout(writer.try_clone().expect("Failed to clone the pipe")) + .output() + .expect("Failed to run dosr"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_eq!(output.status.code(), Some(0), "dosr {arg}: {stderr}"); + assert!(!stderr.contains("panicked"), "dosr {arg}: {stderr}"); + } + } + #[test] fn test_all_dosr() { if let Ok(r) = try_run_dosr( From 10d703e69bf626e425b534cb8b396a6fa15ea23b Mon Sep 17 00:00:00 2001 From: LeChatP Date: Fri, 23 Oct 2026 16:36:52 +0200 Subject: [PATCH 14/88] fix: the PATH of a command never names its working directory * fix: a relative directory of the path option (".", "", "bin") was kept in every mode; it is the working directory of the command, which the user chooses: it is now kept only in keep-unsafe mode, like the relative directories of the user, and logged when left out * fix: with no directory left (delete mode with "add": []), dosr set PATH to the empty string, which shells and execvp(3) search as the working directory; the command now has no PATH, and its own default applies; the set list of the policy can still give one * fix: a PATH set by PAM never fills an absent PATH * tests: relative directories of the policy and of the user, the empty one included, in each mode; no PATH without directories, even when the user's PATH is kept by name or PAM sets one; a task without any directory Co-Authored-By: Claude --- core/src/database/env.rs | 50 ++++++++++++++++++++++++++++++++------- core/src/database/path.rs | 37 ++++++++++++++++++++++++++--- src/dosr/finder/mod.rs | 27 +++++++++++++++++++++ 3 files changed, 103 insertions(+), 11 deletions(-) diff --git a/core/src/database/env.rs b/core/src/database/env.rs index 4a2fbfc9..879d8065 100644 --- a/core/src/database/env.rs +++ b/core/src/database/env.rs @@ -162,13 +162,19 @@ impl<'a> ResolvedEnv<'a> { }) .collect::>(); let path: Vec<_> = path.into_iter().collect(); - final_env.insert( - "PATH".into(), - path.iter() - .map(AsRef::as_ref) - .collect::>() - .join(":"), - ); + if path.is_empty() { + // An empty PATH is the working directory for shells and execvp(3): without any + // directory, the command has no PATH, and its own default applies. + final_env.remove("PATH"); + } else { + final_env.insert( + "PATH".into(), + path.iter() + .map(AsRef::as_ref) + .collect::>() + .join(":"), + ); + } let target_user = target.unwrap_or(¤t_user.user); final_env.insert("LOGNAME".into(), target_user.name.clone()); final_env.insert("USER".into(), target_user.name.clone()); @@ -227,13 +233,19 @@ impl EnvFilter { /// Add to the environment of the command the variables that the filter keeps, unless the /// environment already has them: the kept variables of the user, the variables set by dosr - /// and the `set` list of the policy take precedence. + /// and the `set` list of the policy take precedence. `PATH` is never added: the path options + /// decide it, and leave it out when no directory remains. pub fn add_vars( &self, env: &mut HashMap, vars: impl IntoIterator, ) { for (key, value) in vars { + // the path options decide the PATH, which may be absent on purpose + if key == "PATH" { + debug!("PATH is decided by the path options, the one of PAM is left out"); + continue; + } match env.entry(key) { Entry::Occupied(var) => { debug!("{} is already set, its other value is left out", var.key()); @@ -687,4 +699,26 @@ mod tests { assert!(!final_env.contains_key("LD_PRELOAD")); assert!(!final_env.contains_key("PAM_FUNC")); } + + #[test] + fn test_final_env_without_any_directory_has_no_path() { + let target = cred(); + // even a PATH of the user that the policy keeps by name, or one set by PAM + let env = resolved(EnvBehavior::Delete, &["PATH"], &[], &[]); + let no_dir: [&str; 0] = []; + let mut final_env = env + .final_env([("PATH", ".:/tmp")], no_dir, &target, None, String::new()) + .unwrap(); + assert!(!final_env.contains_key("PATH")); + env.filter() + .add_vars(&mut final_env, [("PATH".into(), ".".into())]); + assert!(!final_env.contains_key("PATH")); + // the set list of the policy can still give one + let mut env = env; + env.set = IndexMap::from([("PATH", "/usr/bin")]); + let final_env = env + .final_env([("PATH", ".:/tmp")], no_dir, &target, None, String::new()) + .unwrap(); + assert_eq!(final_env.get("PATH").unwrap(), "/usr/bin"); + } } diff --git a/core/src/database/path.rs b/core/src/database/path.rs index c3201563..4c2df6c3 100644 --- a/core/src/database/path.rs +++ b/core/src/database/path.rs @@ -5,6 +5,7 @@ //! `PATH` of the user. use indexmap::IndexSet; +use log::warn; use crate::{ database::options::PathBehavior, @@ -56,8 +57,11 @@ impl<'a> ResolvedPath<'a> { /// The `PATH` of the command, from the `PATH` of the user: /// - in delete mode, the directories of the `add` list, - /// - in keep-safe mode, the directories of the `add` list then the absolute directories of - /// the user's `PATH`, in keep-unsafe mode every directory of the user's `PATH`, + /// - in keep-safe mode, the directories of the `add` list then those of the user's `PATH`, + /// in keep-unsafe mode every directory of the user's `PATH`, + /// - a relative directory (`.`, an empty one, `bin`) is the working directory of the command, + /// which the user chooses: only the keep-unsafe mode keeps it, from the policy as from the + /// user, and a relative directory of the policy that is left out is logged, /// - the directories of the `sub` list are always removed, and each directory appears once. /// /// The directories of the policy come first, so they win over those of the user. @@ -70,7 +74,13 @@ impl<'a> ResolvedPath<'a> { }; let keep_relative = self.behavior.is_keep_unsafe(); let mut path: IndexSet<&str> = IndexSet::new(); - path.extend(self.add.iter().copied()); + path.extend(self.add.iter().copied().filter(|dir| { + let kept = keep_relative || dir.starts_with('/'); + if !kept { + warn!("The directory {dir:?} of the path option is relative, it is left out"); + } + kept + })); path.extend( user_path .iter() @@ -170,6 +180,27 @@ mod tests { ); } + #[test] + fn test_relative_directories_only_in_keep_unsafe() { + let path = |behavior| ResolvedPath { + behavior, + add: IndexSet::from(["/sbin", ".", "", "bin"]), + sub: IndexSet::new(), + }; + let user_path = ["/usr/bin", "", ".", "tools"]; + // the relative directories of the policy and of the user are left out, the empty one + // (the working directory too) included + assert_eq!(path(PathBehavior::Delete).final_path(&user_path), ["/sbin"]); + assert_eq!( + path(PathBehavior::KeepSafe).final_path(&user_path), + ["/sbin", "/usr/bin"] + ); + assert_eq!( + path(PathBehavior::KeepUnsafe).final_path(&user_path), + ["/sbin", ".", "", "bin", "/usr/bin", "tools"] + ); + } + #[test] fn test_inheriting_level_extends_the_lists() { let levels = [ diff --git a/src/dosr/finder/mod.rs b/src/dosr/finder/mod.rs index ba923087..fb59479c 100644 --- a/src/dosr/finder/mod.rs +++ b/src/dosr/finder/mod.rs @@ -1925,6 +1925,33 @@ mod tests { settings.env } + #[test] + fn test_a_task_without_any_directory_gives_no_path() { + // an empty PATH would be the working directory for a shell, which the user chooses + let policy = format!( + r#"{{"roles":[{{"name":"r","actors":[{{"type":"user","id":{}}}],"tasks":[{{"name":"t","commands":["/usr/bin/true"],"options":{{"path":{{"default":"delete","add":[".",""]}},"env":{{"default":"delete","keep":["PATH"]}}}}}}]}}]}}"#, + nix::unistd::getuid() + ); + let cli = Cli::builder().cmd_path("/usr/bin/true".to_string()).build(); + let cred = dummy_cred(); + let data = super::de::ConfigFinderDeserializer { + cli: &cli, + cred: &cred, + } + .deserialize(&mut serde_json::Deserializer::from_str(&policy)) + .expect("valid policy"); + let settings = BestExecSettings::retrieve_settings( + &cli, + &cred, + &data, + [("PATH", ".:/tmp")], + &[".", "/usr/bin"], + ) + .unwrap(); + assert!(!settings.env.contains_key("PATH"), "{:?}", settings.env); + assert!(settings.env_path.is_empty(), "{:?}", settings.env_path); + } + #[test] fn test_pam_vars_go_through_the_env_filter_of_the_task() { let pam_vars = [ From 841605cd5d071088a2b262ce1be81b9ba0f9ebb7 Mon Sep 17 00:00:00 2001 From: LeChatP Date: Fri, 23 Oct 2026 16:39:20 +0200 Subject: [PATCH 15/88] fix: chsr creates policy files readable and writable by their owner only * fix: the policy files that chsr opened for writing were created with the mode of the umask, 0644 with the usual 022: the policy was readable by every user, and a umask of 000 made it writable by them, so that dosr refused it; they are now created 0600, like the files of the installers * tests: a new root file or policy file is never more than 0600; saving rewrites the file in place and keeps its owner and mode Co-Authored-By: Claude --- core/src/file.rs | 77 ++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 64 insertions(+), 13 deletions(-) diff --git a/core/src/file.rs b/core/src/file.rs index c12504bf..082fafd4 100644 --- a/core/src/file.rs +++ b/core/src/file.rs @@ -5,6 +5,7 @@ use std::{ fmt::Debug, fs::{File, OpenOptions}, io::{self, BufReader, Seek}, + os::unix::fs::OpenOptionsExt, path::{Path, PathBuf}, rc::Rc, }; @@ -57,6 +58,16 @@ pub struct LockedSettingsFile } /// This opens, deserialize and locks a settings file, and keeps the file descriptor open to keep the lock +/// How a policy file is opened to be written. A new file is only readable and writable by its +/// owner (the umask can only remove more), like the files of the installers: the policy must not +/// be readable, nor writable, by other users (see `check_others_permissions`). An existing file +/// keeps its owner and mode, since saving rewrites it in place. +fn write_options() -> OpenOptions { + let mut options = OpenOptions::new(); + options.read(true).write(true).create(true).mode(0o600); + options +} + /// it allows to save the settings file later impl LockedSettingsFile { /// # Errors @@ -79,12 +90,7 @@ impl LockedSettingsFile { where S: AsRef, { - Self::open( - path, - OpenOptions::new().read(true).write(true).create(true), - true, - data_loader, - ) + Self::open(path, &write_options(), true, data_loader) } /// # Errors @@ -228,7 +234,7 @@ impl FileSettings { rar_cfg_path, rar_cfg_data_path, rar_cfg_type, - OpenOptions::new().read(true).write(true).create(true), + &write_options(), true, ) } @@ -247,12 +253,7 @@ impl FileSettings { where P: AsRef, { - Self::load_policy_file( - cfg_type, - OpenOptions::new().read(true).write(true).create(true), - true, - cfg_path, - ) + Self::load_policy_file(cfg_type, &write_options(), true, cfg_path) } /// # Errors /// Returns an error if any of the files cannot be opened, deserialized or locked @@ -1341,6 +1342,56 @@ mod tests { assert!(!content.contains("oldest_role")); } + /// A path in the temporary directory that does not exist yet, removed when dropped. + fn new_temp_path(name: &str) -> (PathBuf, impl Drop) { + let path = std::env::temp_dir().join(format!("{name}_{}", std::process::id())); + let _ = std::fs::remove_file(&path); + let cleanup = path.clone(); + ( + path, + defer(move || { + let _ = std::fs::remove_file(&cleanup); + }), + ) + } + + #[test] + fn test_new_policy_files_are_private() { + use std::os::unix::fs::PermissionsExt; + let (root_file, _cleanup_root) = new_temp_path("rar_new_root_file"); + let mut locked = + LockedSettingsFile::open_write(&root_file, |_, _| Ok(RootSettings::default())).unwrap(); + locked.save(StorageMethod::JSON, false).unwrap(); + drop(locked); + let (policy_file, _cleanup_policy) = new_temp_path("rar_new_policy_file"); + // the new file is empty, so reading it fails, but the file is created + assert!(FileSettings::write_policy(&policy_file, StorageMethod::JSON).is_err()); + for path in [&root_file, &policy_file] { + let mode = std::fs::metadata(path).unwrap().permissions().mode(); + // never more than 0600, whatever the umask + assert_eq!(mode & 0o077, 0, "{}: {mode:o}", path.display()); + } + } + + #[test] + fn test_saving_keeps_the_owner_and_mode() { + use std::os::unix::fs::{MetadataExt, PermissionsExt}; + let (path, _cleanup) = new_temp_path("rar_saved_root_file"); + std::fs::write(&path, b"{}").unwrap(); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o640)).unwrap(); + let before = std::fs::metadata(&path).unwrap(); + let mut locked = + LockedSettingsFile::open_write(&path, |_, _| Ok(RootSettings::default())).unwrap(); + locked.save(StorageMethod::JSON, false).unwrap(); + drop(locked); + let after = std::fs::metadata(&path).unwrap(); + // rewritten in place: the same file, with its owner and mode + assert_eq!(after.ino(), before.ino()); + assert_eq!((after.uid(), after.gid()), (before.uid(), before.gid())); + assert_eq!(after.permissions().mode() & 0o7777, 0o640); + assert!(after.len() > 2); + } + #[test] fn test_save_all_with_directory_storage() { let test_file = "/tmp/test_save_all_with_directory_storage.json"; From afb99051fe92e9d68e09a69f6aad3d66b38cd812 Mon Sep 17 00:00:00 2001 From: LeChatP Date: Fri, 23 Oct 2026 16:43:04 +0200 Subject: [PATCH 16/88] fix: dosr says why it lacks its capabilities instead of a bare Permission denied * fix: when dosr runs without the capabilities it needs (setpcap, setuid, and dac_read_search or dac_override), reading the policy failed and the user only saw "Permission denied", as if the policy refused the command; dosr now prints why after the error, and logs it * fix: three causes are told apart: no_new_privs (the file capabilities are ignored: a service with NoNewPrivileges=yes, a container, setpriv, or a command that dosr runs with "root": "user", the usual nested session), a bounding set that lacks them (CapabilityBoundingSet=, a container, a command run with a strict bounding set), or a dosr file without capabilities or on a nosuid file system * tests: each cause, and one DAC capability being enough Co-Authored-By: Claude --- src/dosr/lib.rs | 101 +++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 99 insertions(+), 2 deletions(-) diff --git a/src/dosr/lib.rs b/src/dosr/lib.rs index c3615ef3..ba77860a 100644 --- a/src/dosr/lib.rs +++ b/src/dosr/lib.rs @@ -179,6 +179,65 @@ fn cap_effective_error(caplist: &str) -> String { format!("Unable to toggle {caplist} privilege. {CAPABILITIES_ERROR}") } +/// Why dosr lacks the capabilities that it needs to run at all (see [`CAPABILITIES_ERROR`]), +/// from the state of its process: `None` when it has them. +/// +/// Without them, reading the policy fails and the user would only see "Permission denied", as if +/// the policy refused the command. The usual case is dosr run from a command that dosr started: +/// with `root: user`, that command has `no_new_privs`, and under a strict bounding set it lacks +/// the capabilities dosr needs. +fn missing_privileges_reason( + no_new_privs: bool, + permitted: capctl::CapSet, + bounding: capctl::CapSet, +) -> Option { + let mut missing: capctl::CapSet = [Cap::SETPCAP, Cap::SETUID] + .into_iter() + .filter(|cap| !permitted.has(*cap)) + .collect(); + if !permitted.has(Cap::DAC_READ_SEARCH) && !permitted.has(Cap::DAC_OVERRIDE) { + missing.add(Cap::DAC_READ_SEARCH); + } + if missing.is_empty() { + return None; + } + let names = |caps: capctl::CapSet| { + caps.iter() + .map(|cap| cap.to_string()) + .collect::>() + .join(" ") + }; + let outside_bounding = missing & !bounding; + Some(if no_new_privs { + "no_new_privs is set in this session, so the kernel does not give dosr the capabilities \ + of its file: it is set by a service with NoNewPrivileges=yes, a container, \ + setpriv --no-new-privs, or a command that dosr runs with \"root\": \"user\"" + .to_string() + } else if !outside_bounding.is_empty() { + format!( + "the bounding set of this session lacks {}, which dosr needs: it is reduced by a \ + service with CapabilityBoundingSet=, a container, or a command that dosr runs with \ + a strict bounding set", + names(outside_bounding) + ) + } else { + format!( + "dosr lacks {}: its file has no capabilities (setcap '=p'), or its file system is \ + mounted nosuid", + names(missing) + ) + }) +} + +/// [`missing_privileges_reason`] for this process. +fn missing_privileges() -> Option { + let no_new_privs = capctl::prctl::get_no_new_privs().unwrap_or(false); + let permitted = CapState::get_current() + .map(|state| state.permitted) + .unwrap_or_default(); + missing_privileges_reason(no_new_privs, permitted, capctl::bounding::probe()) +} + fn getopt(s: I) -> SrResult where I: IntoIterator, @@ -288,8 +347,16 @@ pub fn dosr_main() { } if let Err(e) = main_inner() { eprintln_ignore_io_error!("dosr: {e}"); - if e == SrError::InsufficientPrivileges { - error!("Insufficient privileges to run sr. {CAPABILITIES_ERROR}"); + // whatever the error, it comes from the missing capabilities when dosr lacks them + let missing = missing_privileges(); + if let Some(reason) = &missing { + eprintln_ignore_io_error!("dosr: {reason}"); + } + if e == SrError::InsufficientPrivileges || missing.is_some() { + error!( + "Insufficient privileges to run dosr: {}", + missing.as_deref().unwrap_or(CAPABILITIES_ERROR) + ); } else if e == SrError::AuthenticationFailed { error!( "Authentication failed for user '{}', when trying running '''{}'''", @@ -1043,6 +1110,36 @@ mod tests { } } + #[test] + fn test_missing_privileges_reason() { + let all = CapSet::from_iter([ + Cap::SETPCAP, + Cap::SETUID, + Cap::SETGID, + Cap::DAC_READ_SEARCH, + Cap::DAC_OVERRIDE, + ]); + assert_eq!(missing_privileges_reason(false, all, all), None); + // one of the two DAC capabilities is enough to read the policy + let only_override = CapSet::from_iter([Cap::SETPCAP, Cap::SETUID, Cap::DAC_OVERRIDE]); + assert_eq!( + missing_privileges_reason(false, only_override, only_override), + None + ); + // the file capabilities are ignored under no_new_privs + let reason = missing_privileges_reason(true, CapSet::empty(), all).unwrap(); + assert!(reason.contains("no_new_privs"), "{reason}"); + // a reduced bounding set names what it lacks + let bounding = CapSet::from_iter([Cap::SETPCAP, Cap::DAC_READ_SEARCH]); + let reason = missing_privileges_reason(false, bounding, bounding).unwrap(); + assert!(reason.contains("bounding set"), "{reason}"); + assert!(reason.contains(&Cap::SETUID.to_string()), "{reason}"); + assert!(!reason.contains(&Cap::SETPCAP.to_string()), "{reason}"); + // in the bounding set but not permitted: the file of dosr + let reason = missing_privileges_reason(false, CapSet::empty(), all).unwrap(); + assert!(reason.contains("setcap"), "{reason}"); + } + #[test] fn test_caps_outside_bounding() { use capctl::{Cap, CapSet}; From 9816099a4cfbf36b8dcd13909bc44c7dd3586a61 Mon Sep 17 00:00:00 2001 From: LeChatP Date: Fri, 23 Oct 2026 16:46:10 +0200 Subject: [PATCH 17/88] fix: chsr prints its usage without the policy and no longer panics on startup errors * fix: chsr created its temporary folder and read the policy before parsing its arguments, both with expect: without the privileges to read the policy, even chsr --help ended in a panic; --help and a usage error are now handled first, without the policy * fix: a failure to create the temporary folder or to read the policy is an error message on stderr and in the log, with a failure exit code * fix: a non-UTF-8 argument is refused instead of panicking in std::env::args() * tests: which arguments need the policy; chsr --help without privileges prints the usage and exits 0 Co-Authored-By: Claude --- src/chsr/cli/mod.rs | 41 +++++++++++++++++++++++++++++++++++++- src/chsr/main.rs | 40 ++++++++++++++++++++++++++++++++----- tests/integration_tests.rs | 17 ++++++++++++++++ 3 files changed, 92 insertions(+), 6 deletions(-) diff --git a/src/chsr/cli/mod.rs b/src/chsr/cli/mod.rs index 76135b41..7505a4d2 100644 --- a/src/chsr/cli/mod.rs +++ b/src/chsr/cli/mod.rs @@ -11,7 +11,7 @@ pub mod usage; use std::{error::Error, path::PathBuf}; use bon::builder; -use data::{Cli, Inputs, Rule}; +use data::{Cli, InputAction, Inputs, Rule}; use landlock::RulesetStatus; use log::debug; @@ -23,6 +23,30 @@ use usage::print_usage; use crate::{cli::editor::start_editing, util::escape_parser_string_vec}; +/// What chsr does without the policy: print the usage for `--help`, or when the arguments +/// cannot be parsed. `None` when the arguments need the policy, which chsr then reads. +pub fn usage_without_policy(args: I) -> Option>> +where + I: IntoIterator, + S: AsRef, +{ + let args = escape_parser_string_vec(args); + let pairs = match Cli::parse(Rule::cli, &args) { + Ok(pairs) => pairs, + Err(e) => return Some(print_usage(e)), + }; + let mut inputs = Inputs::default(); + for pair in pairs { + if let Err(e) = recurse_pair(pair, &mut inputs) { + return Some(Err(e)); + } + } + (inputs.action == InputAction::Help).then(|| { + usage::help(); + Ok(false) + }) +} + #[builder] pub fn main( #[builder(start_fn)] storage: &mut FileSettings, @@ -954,6 +978,21 @@ mod tests { // chsr o workdir setpolicy (all|none|inherit) // chsr o workdir (whitelist|blacklist) (add|del|set|purge) /home/user/** + #[test] + fn test_usage_without_policy() { + // printed without the policy + assert!(matches!(usage_without_policy(["--help"]), Some(Ok(false)))); + assert!(matches!(usage_without_policy(["-h"]), Some(Ok(false)))); + assert!(matches!( + usage_without_policy(["not-a-command"]), + Some(Err(_)) + )); + // these need the policy + assert!(usage_without_policy(["list"]).is_none()); + assert!(usage_without_policy(["role", "r", "add"]).is_none()); + assert!(usage_without_policy(["upgrade"]).is_none()); + } + #[test] fn test_all_main() { let (mut ctx, _defer) = TestContext::new("all_main"); diff --git a/src/chsr/main.rs b/src/chsr/main.rs index e832d236..171155f6 100644 --- a/src/chsr/main.rs +++ b/src/chsr/main.rs @@ -36,6 +36,14 @@ mod cli; mod security; mod util; +/// Report an error that stops chsr, on stderr and in the log. +#[cfg(not(tarpaulin_include))] +fn failure(message: &str) -> std::process::ExitCode { + log::error!("{message}"); + rootasrole_core::eprintln_ignore_io_error!("chsr: {message}"); + std::process::ExitCode::FAILURE +} + #[cfg(not(tarpaulin_include))] fn main() -> Result> { use std::{env::temp_dir, process::ExitCode}; @@ -65,14 +73,36 @@ fn main() -> Result> { Cap::LINUX_IMMUTABLE, ])?; - let folder = nix::unistd::mkdtemp(&temp_dir().join("chsr_XXXXXX")) - .expect("Failed to create temporary folder"); + // argv is untrusted: a non-UTF-8 argument is refused, std::env::args() would panic + let Ok(args) = std::env::args_os() + .skip(1) + .map(std::ffi::OsString::into_string) + .collect::, _>>() + else { + return Ok(failure("an argument is not valid UTF-8")); + }; + // --help and a usage error need neither the policy nor the temporary folder + if let Some(result) = cli::usage_without_policy(&args) { + return Ok(if result.is_ok() { + ExitCode::SUCCESS + } else { + ExitCode::FAILURE + }); + } + + let folder = match nix::unistd::mkdtemp(&temp_dir().join("chsr_XXXXXX")) { + Ok(folder) => folder, + Err(e) => return Ok(failure(&format!("cannot create a temporary folder: {e}"))), + }; let _cleanup = defer(|| { let _ = std::fs::remove_dir_all(&folder); }); - let mut settings = FileSettings::write_all(RAR_CFG_PATH, RAR_CFG_DATA_PATH, RAR_CFG_TYPE) - .expect("Error on config read"); + let mut settings = match FileSettings::write_all(RAR_CFG_PATH, RAR_CFG_DATA_PATH, RAR_CFG_TYPE) + { + Ok(settings) => settings, + Err(e) => return Ok(failure(&format!("cannot read the policy: {e}"))), + }; // Apply Landlock restrictions let ruleset_status = match full_program_lock( @@ -93,7 +123,7 @@ fn main() -> Result> { } }; - match cli::main(&mut settings, std::env::args().skip(1)) + match cli::main(&mut settings, &args) .ruleset(&ruleset_status) .folder(&folder) .call() diff --git a/tests/integration_tests.rs b/tests/integration_tests.rs index 75530b00..415dd2eb 100644 --- a/tests/integration_tests.rs +++ b/tests/integration_tests.rs @@ -62,6 +62,23 @@ mod tests { } } + /// chsr prints its usage without reading the policy, so without any privilege. + #[test] + fn test_chsr_help_without_the_policy() { + let Some(chsr) = option_env!("CARGO_BIN_EXE_chsr") else { + eprintln!("Skipping: chsr is not built (feature editor)"); + return; + }; + let output = std::process::Command::new(chsr) + .arg("--help") + .output() + .expect("Failed to run chsr"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_eq!(output.status.code(), Some(0), "{stderr}"); + assert!(!stderr.contains("panicked"), "{stderr}"); + assert!(String::from_utf8_lossy(&output.stdout).contains("chsr")); + } + #[test] fn test_all_dosr() { if let Ok(r) = try_run_dosr( From 22241144735df23f7ac2564b9f90d8be8f187292 Mon Sep 17 00:00:00 2001 From: LeChatP Date: Fri, 23 Oct 2026 21:03:08 +0200 Subject: [PATCH 18/88] chore: remove the upstream packaging leftovers * chore: packages are made by Debian (debcargo-conf) and the AUR (dosr), not upstream (decided on 2026-10-19): xtask deploy, which could not build the deb and rpm packages anymore since their metadata left Cargo.toml, is removed with its feature * chore: resources/arch/PKGBUILD and rootasrole.install, resources/rh/postinst.sh and prerm.sh, and the Vagrantfiles of tests/capable are removed; the PAM service files of resources/arch and resources/rh stay, since cargo xtask install and uninstall use them Co-Authored-By: Claude --- resources/arch/PKGBUILD | 62 ---------------- resources/arch/rootasrole.install | 60 --------------- resources/rh/postinst.sh | 32 -------- resources/rh/prerm.sh | 1 - tests/capable/centos8/Vagrantfile | 17 ----- tests/capable/debian11/Vagrantfile | 28 ------- tests/capable/fedora37/Vagrantfile | 14 ---- tests/capable/rh9/Vagrantfile | 51 ------------- tests/capable/ubuntu2204/Vagrantfile | 16 ---- xtask/Cargo.toml | 3 +- xtask/src/deploy/debian.rs | 106 --------------------------- xtask/src/deploy/mod.rs | 86 ---------------------- xtask/src/deploy/redhat.rs | 46 ------------ xtask/src/main.rs | 6 +- 14 files changed, 2 insertions(+), 526 deletions(-) delete mode 100644 resources/arch/PKGBUILD delete mode 100644 resources/arch/rootasrole.install delete mode 100644 resources/rh/postinst.sh delete mode 100644 resources/rh/prerm.sh delete mode 100644 tests/capable/centos8/Vagrantfile delete mode 100644 tests/capable/debian11/Vagrantfile delete mode 100644 tests/capable/fedora37/Vagrantfile delete mode 100644 tests/capable/rh9/Vagrantfile delete mode 100644 tests/capable/ubuntu2204/Vagrantfile delete mode 100644 xtask/src/deploy/debian.rs delete mode 100644 xtask/src/deploy/mod.rs delete mode 100644 xtask/src/deploy/redhat.rs diff --git a/resources/arch/PKGBUILD b/resources/arch/PKGBUILD deleted file mode 100644 index d2b8cb67..00000000 --- a/resources/arch/PKGBUILD +++ /dev/null @@ -1,62 +0,0 @@ -# Maintainer: Eddie Billoir - -pkgname=rootasrole -pkgver=4.2.0 -pkgrel=1 -pkgdesc='Alternative to sudo to run some administrative commands that uses Linux capabilities and RBAC for scalability.' -url='https://lechatp.github.io/RootAsRole/' -license=('LGPL-3.0-or-later') -arch=('x86_64') -depends=('libcap' 'e2fsprogs' 'pam') -makedepends=('cargo') -backup=('etc/pam.d/dosr') -validpgpkeys=('74F43C5774BE1F3527DEFA4835C155EA0525104D') -source=("$pkgname-$pkgver.tar.gz::https://static.crates.io/crates/$pkgname/$pkgname-$pkgver.crate") -sha256sums=('SKIP') -install=rootasrole.install - -prepare() { - cd "$pkgname-$pkgver" - export RUSTUP_TOOLCHAIN=stable - cargo fetch --locked --target "$(rustc -vV | sed -n 's/host: //p')" -} - -build() { - cd "$pkgname-$pkgver" - export RUSTUP_TOOLCHAIN=stable - export CARGO_TARGET_DIR=target - cargo build --frozen --release --all-features -} - -check() { - cd "$pkgname-$pkgver" - export RUSTUP_TOOLCHAIN=stable - export CARGO_TARGET_DIR=target - cargo test --frozen --all-features -} - -package() { - cd "$pkgname-$pkgver" - install -Dm755 'target/release/dosr' -t "$pkgdir/usr/bin" - install -Dm755 'target/release/chsr' -t "$pkgdir/usr/bin" - install -Dm644 'resources/arch/arch_sr_pam.conf' "$pkgdir/etc/pam.d/dosr" - # the root file and the default policy, installed by rootasrole.install, - # which also sets the administrator of the default policy - install -dm755 "$pkgdir/usr/share/rootasrole" - sed "s/PACKAGE_VERSION/$pkgver/" 'resources/rootasrole.json' \ - > "$pkgdir/usr/share/rootasrole/default.json" - cat > "$pkgdir/usr/share/rootasrole/rootasrole.json" < "$POLICY_FILE" - chmod 600 "$POLICY_FILE" - log "The default policy lets $admin run every command with dosr, and edit the policy with chsr." - - # dosr refuses a policy file without the immutable flag when the policy is immutable - FS_TYPE=$(df -T "$POLICY_DIR" | awk 'NR==2 {print $2}') - case "$FS_TYPE" in - ext2 | ext3 | ext4 | xfs | btrfs | ocfs2 | jfs | reiserfs) - if chattr +i "$ROOT_FILE" "$POLICY_FILE" && chattr +i "$POLICY_DIR"; then - return 0 - fi - log "Failed to set the immutable flag on the policy" - chattr -i "$POLICY_DIR" "$POLICY_FILE" "$ROOT_FILE" 2>/dev/null - ;; - *) - log "The file system $FS_TYPE does not support the immutable flag." - ;; - esac - # without the flag, the policy is not declared immutable and no task needs to change it - sed -i 's/"immutable": true/"immutable": false/' "$ROOT_FILE" - sed -i 's;"CAP_LINUX_IMMUTABLE";;g' "$POLICY_FILE" -} - -post_upgrade() { - set_capabilities - if [ -f "$ROOT_FILE" ] && ! chsr upgrade; then - log "Some policy files could not be upgraded, check them with 'chsr upgrade --check'" - fi -} diff --git a/resources/rh/postinst.sh b/resources/rh/postinst.sh deleted file mode 100644 index 1267b1ed..00000000 --- a/resources/rh/postinst.sh +++ /dev/null @@ -1,32 +0,0 @@ -#!/bin/sh -if [ -z ${SUDO_USER+x} ]; then INSTALL_USER=$(id -urn); else INSTALL_USER=$SUDO_USER; fi - -filesystem() { - df -T "$1" | awk 'NR==2 {print $2}' -} - -configure() { - echo "Configuring rootasrole.json" - sed -i "s/ROOTADMINISTRATOR/$INSTALL_USER/g" /etc/security/rootasrole.json - FS=$(filesystem /etc/security/rootasrole.json) - case $FS in - "ext2" | "ext3" | "ext4" | "xfs" | "btrfs" | "ocfs2" | "jfs" | "reiserfs") - echo "Setting immutable attribute on /etc/security/rootasrole.json" - chattr +i /etc/security/rootasrole.json - ;; - *) - echo "filesystem $FS does not support immutable attribute" - echo "Removing immutable parameter from /etc/security/rootasrole.json" - sed -i "s/\"immutable\": true/\"immutable\": false/g" /etc/security/rootasrole.json - sed -i "s;\"CAP_LINUX_IMMUTABLE\";;g" /etc/security/rootasrole.json - ;; - esac -} - -if [ -f /etc/security/rootasrole.json ]; then - if grep -q "ROOTADMINISTRATOR" /etc/security/rootasrole.json; then - configure - elif ! chsr upgrade; then - echo "Some policy files could not be upgraded, check them with 'chsr upgrade --check'" - fi -fi diff --git a/resources/rh/prerm.sh b/resources/rh/prerm.sh deleted file mode 100644 index c0a05adb..00000000 --- a/resources/rh/prerm.sh +++ /dev/null @@ -1 +0,0 @@ -chattr -i /etc/security/rootasrole.json 2>/dev/null || true \ No newline at end of file diff --git a/tests/capable/centos8/Vagrantfile b/tests/capable/centos8/Vagrantfile deleted file mode 100644 index ce42ed96..00000000 --- a/tests/capable/centos8/Vagrantfile +++ /dev/null @@ -1,17 +0,0 @@ -Vagrant.configure("2") do |config| - config.vm.box = "generic/centos8" - - config.vm.provision "shell", inline: <<-SHELL - sed -i 's/mirrorlist/#mirrorlist/g' /etc/yum.repos.d/CentOS-*; - sed -i 's|#baseurl=http://mirror.centos.org|baseurl=http://vault.centos.org|g' /etc/yum.repos.d/CentOS-* - yum clean all - yum install -y git make - sudo -u vagrant sh -c 'git clone -b develop https://github.com/LeChatP/RootAsRole; - cd RootAsRole; - ./dependencies.sh -y; - sudo ./configure.sh -y; - make install; - dosr capable -j cat /etc/shadow;' - SHELL - end - \ No newline at end of file diff --git a/tests/capable/debian11/Vagrantfile b/tests/capable/debian11/Vagrantfile deleted file mode 100644 index fb34c0d9..00000000 --- a/tests/capable/debian11/Vagrantfile +++ /dev/null @@ -1,28 +0,0 @@ -Vagrant.configure("2") do |config| - config.vm.box = "debian/bullseye64" - config.vm.synced_folder '.', '/vagrant', disabled: true - - config.vm.provider "libvirt" do |vb| - vb.memory = "2048" - end - - config.vm.provision "shell", inline: <<-SHELL - apt-get update - apt-get install -y git build-essential - apt-get upgrade -y - SHELL - config.vm.provision :shell do |shell| - shell.privileged = true - shell.inline = 'echo rebooting' - shell.reboot = true - end - config.vm.provision "shell", inline: <<-SHELL - sudo -u vagrant sh -c 'git clone -b develop https://github.com/LeChatP/RootAsRole; - cd RootAsRole; - ./dependencies.sh -y; - sudo ./configure.sh -y; - PROFILE=debug make install; - dosr capable -j cat /etc/shadow;' - SHELL -end - \ No newline at end of file diff --git a/tests/capable/fedora37/Vagrantfile b/tests/capable/fedora37/Vagrantfile deleted file mode 100644 index 48b5d7d6..00000000 --- a/tests/capable/fedora37/Vagrantfile +++ /dev/null @@ -1,14 +0,0 @@ -Vagrant.configure("2") do |config| - config.vm.box = "generic/fedora37" - - config.vm.provision "shell", inline: <<-SHELL - dnf install -y git gcc make - sudo -u vagrant sh -c 'git clone -b develop https://github.com/LeChatP/RootAsRole; - cd RootAsRole; - ./dependencies.sh -y; - sudo ./configure.sh -y; - PROFILE=debug make install; - dosr capable -j cat /etc/shadow;' - SHELL - end - \ No newline at end of file diff --git a/tests/capable/rh9/Vagrantfile b/tests/capable/rh9/Vagrantfile deleted file mode 100644 index d4d0f08f..00000000 --- a/tests/capable/rh9/Vagrantfile +++ /dev/null @@ -1,51 +0,0 @@ -Vagrant.require_version ">= 2.1.0" # 2.1.0 minimum required for triggers - -user = ENV['RH_USER'] -password = ENV['RH_PW'] -pool_id = ENV['RH_POOL'] -if !user or !password - puts 'Required environment variables not found. Please set RH_USER and RH_PW' - abort -end - - -register_script = %{ -if ! subscription-manager status; then - sudo subscription-manager register --username="#{user}" --password="#{password}" - sudo subscription-manager attach --pool="#{pool_id}" -fi -} - -unregister_script = %{ -if subscription-manager status; then - sudo subscription-manager unregister -fi -} - -Vagrant.configure("2") do |config| - config.vm.box = "generic/rhel9" - # Disable guest additions check, because at this point the VM - # will not be registered with RHEL via subsctiption-manager - # and yum install will not work. - - - config.vm.provision "shell", inline: register_script - - config.vm.provision "shell", inline: <<-SHELL - yum install -y git gcc make - sudo -u vagrant sh -c 'git clone -b develop https://github.com/LeChatP/RootAsRole; - cd RootAsRole; - ./dependencies.sh -y; - sudo ./configure.sh -y; - PROFILE=debug make install; - dosr capable -j cat /etc/shadow;' - SHELL - - config.trigger.before :destroy do |trigger| - trigger.name = "Before Destroy trigger" - trigger.info = "Unregistering this VM from RedHat Subscription Manager..." - trigger.warn = "If this fails, unregister VMs manually at https://access.redhat.com/management/subscriptions" - trigger.run_remote = {inline: unregister_script} - trigger.on_error = :continue - end # trigger.before :destroy -end # vagrant configure \ No newline at end of file diff --git a/tests/capable/ubuntu2204/Vagrantfile b/tests/capable/ubuntu2204/Vagrantfile deleted file mode 100644 index 78e25ace..00000000 --- a/tests/capable/ubuntu2204/Vagrantfile +++ /dev/null @@ -1,16 +0,0 @@ -Vagrant.configure("2") do |config| - config.vm.box = "generic/ubuntu2204" - - config.vm.provision "shell", inline: <<-SHELL - apt-get update - apt-get install -y git build-essential - sudo -u vagrant sh -c 'git clone -b develop https://github.com/LeChatP/RootAsRole; - cd RootAsRole; - ./dependencies.sh -y; - sudo ./configure.sh -y; - PROFILE=debug make install; - dosr chsr role r_root task t_capable o env set RUST_LOG=debug - dosr capable -j cat /etc/shadow;' - SHELL - end - \ No newline at end of file diff --git a/xtask/Cargo.toml b/xtask/Cargo.toml index 35cf0ac6..8a9dee9c 100644 --- a/xtask/Cargo.toml +++ b/xtask/Cargo.toml @@ -31,10 +31,9 @@ konst = { version= "0.4", default-features = false, features = ["parsing_proc", semver = { version = "1.0", default-features = false, features = ["std", "serde"] } [features] -default = ["cli", "ebpf", "deploy"] +default = ["cli", "ebpf"] cli = [] ebpf = [] -deploy = [] [lints.rust] dead-code = { level = "allow" } diff --git a/xtask/src/deploy/debian.rs b/xtask/src/deploy/debian.rs deleted file mode 100644 index b3928537..00000000 --- a/xtask/src/deploy/debian.rs +++ /dev/null @@ -1,106 +0,0 @@ -use std::{ - fs::File, - io::{BufRead, Write}, - path::Path, - process::Command, -}; - -use anyhow::{Context, anyhow}; -use jiff::Zoned; -use log::debug; - -use crate::{ - installer::{self, InstallDependenciesOptions, Profile, dependencies::install_dependencies}, - util::{OsTarget, PACKAGE_VERSION, detect_priv_bin, get_os, output_checked, run_checked}, -}; - -use super::setup_maint_scripts; - -fn dependencies(os: &OsTarget, priv_bin: Option<&Path>) -> Result<(), anyhow::Error> { - install_dependencies(os, &["upx"], priv_bin) - .context("failed to install packaging dependencies")?; - run_checked( - Command::new("cargo").arg("install").arg("cargo-deb"), - "install cargo-deb", - ) - .context("failed to install cargo-deb") -} - -fn generate_changelog() -> Result<(), anyhow::Error> { - let changelog_path = "target/debian/changelog"; - if std::path::Path::new(changelog_path).exists() { - return Ok(()); - } - let binding = output_checked( - Command::new("git").args(["tag", "--sort=-creatordate"]), - "list git tags", - )?; - let mut ordered_tags = binding.stdout.lines(); - - let from = ordered_tags - .next() - .ok_or_else(|| anyhow!("No git tag found for changelog generation"))??; - - let to = ordered_tags - .next() - .ok_or_else(|| anyhow!("At least two git tags are required for changelog generation"))??; - - debug!("Generating changelog from {from} to {to}"); - - let changes = output_checked( - Command::new("git").args(["log", "--pretty=format: %s", &format!("{to}..{from}")]), - "collect changelog entries", - )?; - debug!( - "Changes: {}", - String::from_utf8(changes.stdout.clone()) - .expect("Failed to convert git log output to string") - ); - let changelog = format!( - r"rootasrole ({version}) {dist}; urgency={urgency} -{changes} - - -- Eddie Billoir {date} -", - version = PACKAGE_VERSION, - dist = "unstable", - urgency = "low", - changes = - String::from_utf8(changes.stdout).expect("Failed to convert git log output to string"), - date = Zoned::now().strftime("%a, %d %b %Y %T %z") - ); - File::create(changelog_path)?.write_all(changelog.as_bytes())?; - - Ok(()) -} - -pub fn make_deb( - os: Option<&OsTarget>, - profile: Profile, - priv_bin: Option<&Path>, -) -> Result<(), anyhow::Error> { - let os = get_os(os)?; - let priv_bin = priv_bin.map(Path::to_path_buf).or_else(detect_priv_bin); - dependencies(&os, priv_bin.as_deref())?; - - installer::dependencies(&InstallDependenciesOptions { - os: Some(os), - install_dependencies: true, - dev: true, - priv_bin: priv_bin.clone(), - })?; - installer::build(&installer::BuildOptions { - profile, - toolchain: installer::Toolchain::default(), - clean_before: false, - priv_bin, - })?; - setup_maint_scripts()?; - generate_changelog()?; - - run_checked( - Command::new("cargo").arg("deb").arg("--no-build"), - "generate deb package", - )?; - Ok(()) -} diff --git a/xtask/src/deploy/mod.rs b/xtask/src/deploy/mod.rs deleted file mode 100644 index d1fe547b..00000000 --- a/xtask/src/deploy/mod.rs +++ /dev/null @@ -1,86 +0,0 @@ -use std::{collections::HashSet, path::PathBuf, process::Command}; - -use clap::Parser; - -use crate::util::{is_dry_run, run_checked}; -use crate::{installer::Profile, util::OsTarget}; - -mod debian; -mod redhat; - -#[derive(Debug, Parser)] -pub struct MakeOptions { - /// Build the target with debug profile (default is release) - #[clap(short = 'd', long = "debug", default_value_t = Profile::Release, default_missing_value = "debug", num_args = 0)] - pub profile: Profile, - - /// The current OS where the binary is running - #[clap(long, short)] - pub os: Option, - - /// The OS target for package generation - pub target: Vec, - - /// The binary to elevate privileges - #[clap(long, short = 'p', visible_alias = "privbin")] - pub priv_bin: Option, -} - -fn all() -> HashSet { - vec![OsTarget::Debian, OsTarget::ArchLinux, OsTarget::RedHat] - .into_iter() - .collect() -} - -pub fn deploy(opts: &MakeOptions) -> Result<(), anyhow::Error> { - if is_dry_run() { - log::debug!("Dry-run mode: skipping deploy changes"); - return Ok(()); - } - - let targets = if opts.target.is_empty() { - all() - } else { - opts.target.iter().cloned().collect::>() - }; - - for target in targets { - match target { - OsTarget::Debian => { - debian::make_deb(opts.os.as_ref(), opts.profile, opts.priv_bin.as_deref())?; - } - OsTarget::RedHat => { - redhat::make_rpm(opts.os.as_ref(), opts.profile, opts.priv_bin.as_deref())?; - } - _ => anyhow::bail!("Unsupported OS target"), - } - } - - Ok(()) -} - -pub fn setup_maint_scripts() -> Result<(), anyhow::Error> { - run_checked( - Command::new("cargo") - .arg("build") - .arg("--package") - .arg("xtask") - .arg("--no-default-features") - .arg("--release") - .arg("--bin") - .arg("postinst") - .arg("--bin") - .arg("prerm"), - "build maintenance scripts", - )?; - compress("target/release/postinst")?; - compress("target/release/prerm") -} - -fn compress(script: &str) -> Result<(), anyhow::Error> { - run_checked( - Command::new("upx").arg("--best").arg("--lzma").arg(script), - &format!("compress script {script}"), - )?; - Ok(()) -} diff --git a/xtask/src/deploy/redhat.rs b/xtask/src/deploy/redhat.rs deleted file mode 100644 index c472fef9..00000000 --- a/xtask/src/deploy/redhat.rs +++ /dev/null @@ -1,46 +0,0 @@ -use std::path::Path; -use std::process::Command; - -use crate::{ - installer::{self, InstallDependenciesOptions, Profile}, - util::{OsTarget, detect_priv_bin, get_os, run_checked}, -}; - -fn install_dependencies() -> Result<(), anyhow::Error> { - run_checked( - Command::new("cargo") - .arg("install") - .arg("cargo-generate-rpm"), - "install cargo-generate-rpm", - )?; - Ok(()) -} - -pub fn make_rpm( - os: Option<&OsTarget>, - profile: Profile, - exe: Option<&Path>, -) -> Result<(), anyhow::Error> { - install_dependencies()?; - let os = get_os(os)?; - let exe = exe.map(Path::to_path_buf).or_else(detect_priv_bin); - - installer::dependencies(&InstallDependenciesOptions { - os: Some(os), - install_dependencies: true, - dev: true, - priv_bin: exe.clone(), - })?; - installer::build(&installer::BuildOptions { - profile, - toolchain: installer::Toolchain::default(), - clean_before: false, - priv_bin: exe, - })?; - - run_checked( - Command::new("cargo").arg("generate-rpm"), - "generate rpm package", - )?; - Ok(()) -} diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 790afe80..87c713c8 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -1,10 +1,9 @@ mod configure; -mod deploy; mod doctor; mod installer; pub mod util; -use Command::{Build, Configure, Dependencies, Deploy, Doctor, Install, Uninstall}; +use Command::{Build, Configure, Dependencies, Doctor, Install, Uninstall}; use std::process::exit; @@ -37,8 +36,6 @@ enum Command { os: Option, }, Uninstall(installer::UninstallOptions), - #[cfg(feature = "deploy")] - Deploy(deploy::MakeOptions), Doctor(doctor::DoctorOptions), } @@ -64,7 +61,6 @@ fn main() { Install(opts) => installer::install(&opts), Configure { os } => installer::configure(os), Uninstall(opts) => installer::uninstall(&opts), - Deploy(opts) => deploy::deploy(&opts), Doctor(opts) => doctor::doctor(&opts), }; From 105be6a9754bea150370ce94f3e0a4e92d5d25f0 Mon Sep 17 00:00:00 2001 From: LeChatP Date: Fri, 23 Oct 2026 21:03:08 +0200 Subject: [PATCH 19/88] tests: remove a redundant clone that clippy --workspace refuses * tests: json! serializes an interpolated value by reference, so the commands of test_commands_without_arguments_up_and_down need no clone; only clippy on the whole workspace lints the tests of rootasrole-core Co-Authored-By: Claude --- core/src/database/migrations.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/src/database/migrations.rs b/core/src/database/migrations.rs index 4660a641..352c01f2 100644 --- a/core/src/database/migrations.rs +++ b/core/src/database/migrations.rs @@ -562,7 +562,7 @@ mod tests { fn test_commands_without_arguments_up_and_down() { // the upgrade leaves them as written: since 4.1.0 they allow no argument let commands = json!(["/usr/bin/id", "**", "/usr/bin/ls ^.*$"]); - let mut document = json!({"roles": [{"tasks": [{"commands": commands.clone()}]}]}); + let mut document = json!({"roles": [{"tasks": [{"commands": commands}]}]}); Migration::migrate_from( &Version::new(4, 0, 3), &PACKAGE_VERSION, From 30918220b79cce05fdc319e50eef8681001d6a39 Mon Sep 17 00:00:00 2001 From: LeChatP Date: Fri, 23 Oct 2026 21:04:26 +0200 Subject: [PATCH 20/88] fix: chsr creates its temporary folder in /tmp, whatever TMPDIR says * fix: a policy in keep mode, or dosr -E where allowed, passes the user's TMPDIR to chsr: its 0700 folder was then created in a directory that the user may own, where the user can rename it and put another one in its place; /tmp is sticky (decided by the maintainer on 2026-10-23) Co-Authored-By: Claude --- src/chsr/main.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/chsr/main.rs b/src/chsr/main.rs index 171155f6..28207b6e 100644 --- a/src/chsr/main.rs +++ b/src/chsr/main.rs @@ -46,7 +46,7 @@ fn failure(message: &str) -> std::process::ExitCode { #[cfg(not(tarpaulin_include))] fn main() -> Result> { - use std::{env::temp_dir, process::ExitCode}; + use std::{path::Path, process::ExitCode}; use crate::{ cli::editor::defer, @@ -90,7 +90,9 @@ fn main() -> Result> { }); } - let folder = match nix::unistd::mkdtemp(&temp_dir().join("chsr_XXXXXX")) { + // not in TMPDIR, which the user chooses when the policy keeps it: a folder in a directory + // that the user owns can be renamed and replaced; /tmp is sticky + let folder = match nix::unistd::mkdtemp(&Path::new("/tmp").join("chsr_XXXXXX")) { Ok(folder) => folder, Err(e) => return Ok(failure(&format!("cannot create a temporary folder: {e}"))), }; From 09d0ea1b2545276e4dc4f06b9d00a4fa072901d3 Mon Sep 17 00:00:00 2001 From: LeChatP Date: Fri, 23 Oct 2026 21:11:10 +0200 Subject: [PATCH 21/88] fix: a failed lookup of an actor disables the roles with a separation of duties * fix: an actor written by name whose lookup failed (a directory service that does not answer, a group whose members take more than the 1 MiB that nix allows for getgrnam_r) was taken as a name that nobody has: the user did not hold the role, and the SSD plugin let the user take a role that excludes it * fix: dosr now tells a failed lookup from an unknown name: the actor still never matches, the failure is logged, and every role of the policy file that has an ssd is disabled, since its separation of duties cannot be checked (decided by the maintainer on 2026-10-23); SSD only names roles of the same file * feat: rootasrole-core gets DUserType::try_fetch_id and DGroupType::try_fetch_id, fetch_id keeps its meaning * tests: a failed lookup never matches and is reported, even with another matching actor; the roles with ssd are disabled whatever role the failed lookup belongs to, the others are not, through the hierarchy too Co-Authored-By: Claude --- core/src/database/actor.rs | 45 +++++--- src/dosr/finder/api/ssd.rs | 17 ++- src/dosr/finder/de/mod.rs | 4 + src/dosr/finder/de/roles.rs | 215 ++++++++++++++++++++++++++++-------- src/dosr/finder/mod.rs | 80 ++++++++++++++ 5 files changed, 300 insertions(+), 61 deletions(-) diff --git a/core/src/database/actor.rs b/core/src/database/actor.rs index 9c9205ca..4590cba9 100644 --- a/core/src/database/actor.rs +++ b/core/src/database/actor.rs @@ -78,16 +78,24 @@ impl SUserType { } impl DUserType<'_> { - #[must_use] - pub fn fetch_id(&self) -> Option { + /// The uid of the actor, a name being looked up: `Ok(None)` when no user has that name, an + /// error when the lookup itself fails (a directory service that does not answer...). + /// + /// # Errors + /// The error of `getpwnam_r(3)`. + pub fn try_fetch_id(&self) -> nix::Result> { match &self.0 { - DGenericActorType::Id(id) => Some(*id), - DGenericActorType::Name(name) => match User::from_name(name) { - Ok(Some(user)) => Some(user.uid.as_raw()), - _ => None, - }, + DGenericActorType::Id(id) => Ok(Some(*id)), + DGenericActorType::Name(name) => { + Ok(User::from_name(name)?.map(|user| user.uid.as_raw())) + } } } + /// [`Self::try_fetch_id`], a failed lookup being no user. + #[must_use] + pub fn fetch_id(&self) -> Option { + self.try_fetch_id().ok().flatten() + } #[must_use] pub fn fetch_user(&self) -> Option { match &self.0 { @@ -154,16 +162,25 @@ impl SGroupType { } impl DGroupType<'_> { - #[must_use] - pub fn fetch_id(&self) -> Option { + /// The gid of the actor, a name being looked up: `Ok(None)` when no group has that name, an + /// error when the lookup itself fails (a directory service that does not answer, a group + /// whose members take more than the 1 MiB that nix allows for `getgrnam_r(3)`...). + /// + /// # Errors + /// The error of `getgrnam_r(3)`. + pub fn try_fetch_id(&self) -> nix::Result> { match &self.0 { - DGenericActorType::Id(id) => Some(*id), - DGenericActorType::Name(name) => match Group::from_name(name) { - Ok(Some(group)) => Some(group.gid.as_raw()), - _ => None, - }, + DGenericActorType::Id(id) => Ok(Some(*id)), + DGenericActorType::Name(name) => { + Ok(Group::from_name(name)?.map(|group| group.gid.as_raw())) + } } } + /// [`Self::try_fetch_id`], a failed lookup being no group. + #[must_use] + pub fn fetch_id(&self) -> Option { + self.try_fetch_id().ok().flatten() + } #[must_use] pub fn fetch_group(&self) -> Option { match &self.0 { diff --git a/src/dosr/finder/api/ssd.rs b/src/dosr/finder/api/ssd.rs index a744e718..da572925 100644 --- a/src/dosr/finder/api/ssd.rs +++ b/src/dosr/finder/api/ssd.rs @@ -3,7 +3,7 @@ use std::{ hash::{DefaultHasher, Hash, Hasher}, }; -use log::debug; +use log::{debug, warn}; use serde_json::Value; #[cfg(feature = "hierarchy")] @@ -57,6 +57,21 @@ pub(super) fn excludes(role: &DLinkedRole) -> SrResult { } fn check_ssd_recursive(role: &DLinkedRole, visited: &mut HashSet) -> SrResult { + // a failed lookup of an actor leaves unknown whether the user holds a role: the separation + // of duties cannot be checked, so every role of the policy file that has one is disabled + // (decided by the maintainer on 2026-10-23); SSD only names roles of the same file + if role.role().extra_values.contains_key("ssd") + && role + .config() + .roles() + .any(|other| other.role().lookup_failed) + { + warn!( + "The role {} is disabled: it has a separation of duties, and an actor of its policy could not be looked up", + role.role().role + ); + return Ok(true); + } if let Some(Value::Array(ssd)) = role.role().extra_values.get("ssd") { for ssd in ssd { if let Value::String(ssd) = ssd { diff --git a/src/dosr/finder/de/mod.rs b/src/dosr/finder/de/mod.rs index 8c3bf3be..c202fbff 100644 --- a/src/dosr/finder/de/mod.rs +++ b/src/dosr/finder/de/mod.rs @@ -43,6 +43,10 @@ pub struct DConfigFinder<'a> { pub struct DRoleFinder<'a> { #[cfg_attr(test, builder(default))] pub user_min: ActorMatchMin, + /// Looking up an actor of the role written by name failed: whether the user holds the + /// role is unknown, and it is not matched + #[cfg_attr(test, builder(default))] + pub lookup_failed: bool, #[cfg_attr(test, builder(into))] pub role: Cow<'a, str>, #[cfg_attr(test, builder(default))] diff --git a/src/dosr/finder/de/roles.rs b/src/dosr/finder/de/roles.rs index 036bcbf9..5198a10e 100644 --- a/src/dosr/finder/de/roles.rs +++ b/src/dosr/finder/de/roles.rs @@ -1,14 +1,13 @@ use std::{borrow::Cow, collections::HashMap}; use log::{debug, warn}; -use nix::unistd::{Gid, Group}; use rootasrole_core::{ Cred, database::{ - actor::{DActor, DGroups}, + actor::{DActor, DGroupType, DGroups, DUserType}, score::ActorMatchMin, }, - util::{Either, StorageMethod}, + util::{StorageMethod, either_to_gid}, }; use serde::{ Deserialize, @@ -119,6 +118,7 @@ impl<'de: 'a, 'a> DeserializeSeed<'de> for RoleFinderDeserializer<'a> { let mut options = None; let mut extra_values = HashMap::new(); let mut user_min = ActorMatchMin::default(); + let mut lookup_failed = false; while let Some(key) = map.next_key()? { match key { Field::Options => { @@ -142,8 +142,11 @@ impl<'de: 'a, 'a> DeserializeSeed<'de> for RoleFinderDeserializer<'a> { } Field::Actors => { debug!("RoleFinderVisitor: actors"); - user_min = - map.next_value_seed(ActorsFinderDeserializer { cred: self.cred })?; + (user_min, lookup_failed) = + map.next_value_seed(ActorsFinderDeserializer { + cred: self.cred, + lookup: SYSTEM_LOOKUP, + })?; } Field::Tasks => { debug!("RoleFinderVisitor: tasks"); @@ -159,6 +162,7 @@ impl<'de: 'a, 'a> DeserializeSeed<'de> for RoleFinderDeserializer<'a> { } Ok(Some(DRoleFinder { user_min, + lookup_failed, role: role.unwrap_or_default(), tasks, options, @@ -177,20 +181,91 @@ impl<'de: 'a, 'a> DeserializeSeed<'de> for RoleFinderDeserializer<'a> { struct ActorsFinderDeserializer<'a> { cred: &'a Cred, + lookup: ActorLookup, +} + +/// How the actors written by name are looked up: in the system databases (NSS), or with a +/// stand-in in the tests, to make a lookup fail. +#[derive(Clone, Copy)] +struct ActorLookup { + user: fn(&DUserType<'_>) -> nix::Result>, + group: fn(&DGroupType<'_>) -> nix::Result>, +} + +fn system_user_id(user: &DUserType<'_>) -> nix::Result> { + user.try_fetch_id() +} + +fn system_group_id(group: &DGroupType<'_>) -> nix::Result> { + group.try_fetch_id() +} + +const SYSTEM_LOOKUP: ActorLookup = ActorLookup { + user: system_user_id, + group: system_group_id, +}; + +/// How well an actor matches the user, and whether looking a name up failed. A failed lookup +/// never matches, so it never grants a role; the SSD plugin disables the roles with a +/// separation of duties, which it cannot check then (`finder::api::ssd`). +fn actor_matches(user: &Cred, actor: &DActor<'_>, lookup: ActorLookup) -> (ActorMatchMin, bool) { + match actor { + DActor::User { id, .. } => match (lookup.user)(id) { + Ok(uid) if uid == Some(user.user.uid.as_raw()) => (ActorMatchMin::UserMatch, false), + Ok(_) => (ActorMatchMin::NoMatch, false), + Err(e) => { + warn!("Cannot look up the user {id}: {e}"); + (ActorMatchMin::NoMatch, true) + } + }, + DActor::Group { groups, .. } => { + // the groups of the user are kernel gids: only the policy side is looked up + let user_gids: Vec = user + .groups + .iter() + .map(|group| either_to_gid(group).as_raw()) + .collect(); + let mut failed = false; + let mut holds = |group: &DGroupType<'_>| match (lookup.group)(group) { + Ok(gid) => gid.is_some_and(|gid| user_gids.contains(&gid)), + Err(e) => { + warn!("Cannot look up the group {group}: {e}"); + failed = true; + false + } + }; + let matched = match groups { + DGroups::Single(group) => holds(group), + DGroups::Multiple(groups) => groups.iter().all(holds), + }; + if matched { + (ActorMatchMin::GroupMatch(groups.len()), failed) + } else { + (ActorMatchMin::NoMatch, failed) + } + } + DActor::Unknown(element) => { + // a type of actor that this version does not know never matches + warn!("Unknown actor type, it never matches: {element:?}"); + (ActorMatchMin::NoMatch, false) + } + } } impl<'de> DeserializeSeed<'de> for ActorsFinderDeserializer<'_> { - type Value = ActorMatchMin; + /// The best match of the actors, and whether looking one of them up failed. + type Value = (ActorMatchMin, bool); fn deserialize(self, deserializer: D) -> Result where D: serde::Deserializer<'de>, { struct ActorsFinderVisitor<'a> { cred: &'a Cred, + lookup: ActorLookup, } impl<'de> Visitor<'de> for ActorsFinderVisitor<'_> { - type Value = ActorMatchMin; + type Value = (ActorMatchMin, bool); fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { formatter.write_str("a set of users") } @@ -199,54 +274,24 @@ impl<'de> DeserializeSeed<'de> for ActorsFinderDeserializer<'_> { A: serde::de::SeqAccess<'de>, { let mut user_matches = ActorMatchMin::NoMatch; + let mut lookup_failed = false; while let Some(actor) = seq.next_element::()? { debug!("ActorsSettingsVisitor: actor {actor:?}"); - let temp = Self::user_matches(self.cred, &actor); + let (temp, failed) = actor_matches(self.cred, &actor, self.lookup); + lookup_failed |= failed; if temp != ActorMatchMin::NoMatch && temp < user_matches { debug!("ActorsSettingsVisitor: Better actor found {temp:?}"); user_matches = temp; } } - Ok(user_matches) + Ok((user_matches, lookup_failed)) } } - impl ActorsFinderVisitor<'_> { - fn match_groups(groups: &[Either], role_groups: &[&DGroups<'_>]) -> bool { - for role_group in role_groups { - if match role_group { - DGroups::Single(group) => groups.iter().any(|g| group == g), - DGroups::Multiple(multiple_actors) => multiple_actors - .iter() - .all(|actor| groups.iter().any(|g| actor == g)), - } { - return true; - } - } - false - } - fn user_matches(user: &Cred, actor: &DActor<'_>) -> ActorMatchMin { - match actor { - DActor::User { id, .. } => { - if *id == user.user { - return ActorMatchMin::UserMatch; - } - } - DActor::Group { groups, .. } => { - if Self::match_groups(&user.groups, &[groups]) { - return ActorMatchMin::GroupMatch(groups.len()); - } - } - DActor::Unknown(element) => { - // a type of actor that this version does not know never matches - warn!("Unknown actor type, it never matches: {element:?}"); - } - } - ActorMatchMin::NoMatch - } - } - - deserializer.deserialize_seq(ActorsFinderVisitor { cred: self.cred }) + deserializer.deserialize_seq(ActorsFinderVisitor { + cred: self.cred, + lookup: self.lookup, + }) } } @@ -269,11 +314,88 @@ mod test { .user() .unwrap() .build(), + lookup: SYSTEM_LOOKUP, }; let result = deserializer.deserialize(&mut serde_json::Deserializer::from_str(&json)); assert!(result.is_ok(), "Failed to deserialize: {result:?}"); - let user_min = result.unwrap(); + let (user_min, lookup_failed) = result.unwrap(); assert_eq!(user_min, ActorMatchMin::UserMatch); + assert!(!lookup_failed); + } + + fn failing_user_lookup(_: &DUserType<'_>) -> nix::Result> { + Err(nix::Error::EIO) + } + + fn failing_group_lookup(_: &DGroupType<'_>) -> nix::Result> { + Err(nix::Error::ERANGE) + } + + #[test] + fn test_a_failed_lookup_never_matches_and_is_reported() { + let cred = Cred::builder() + .curdir() + .unwrap() + .groups() + .unwrap() + .user() + .unwrap() + .build(); + let failing = ActorLookup { + user: failing_user_lookup, + group: failing_group_lookup, + }; + let me = format!(r#"{{"type": "user", "id": "{}"}}"#, cred.user.name); + let me: DActor<'_> = serde_json::from_str(&me).unwrap(); + let my_group = format!(r#"{{"type": "group", "groups": [{}]}}"#, getgid().as_raw()); + let my_group: DActor<'_> = serde_json::from_str(&my_group).unwrap(); + let named_group: DActor<'_> = + serde_json::from_str(r#"{"type": "group", "groups": ["rar-no-such-group"]}"#).unwrap(); + let unknown: DActor<'_> = + serde_json::from_str(r#"{"type": "user", "id": "rar-no-such-user"}"#).unwrap(); + // the system databases: a match, and a name that nobody has, without any failure + assert_eq!( + actor_matches(&cred, &me, SYSTEM_LOOKUP), + (ActorMatchMin::UserMatch, false) + ); + assert_eq!( + actor_matches(&cred, &unknown, SYSTEM_LOOKUP), + (ActorMatchMin::NoMatch, false) + ); + assert_eq!( + actor_matches(&cred, &named_group, SYSTEM_LOOKUP), + (ActorMatchMin::NoMatch, false) + ); + // a failed lookup never matches, even for the user itself, and is reported + assert_eq!( + actor_matches(&cred, &me, failing), + (ActorMatchMin::NoMatch, true) + ); + assert_eq!( + actor_matches(&cred, &named_group, failing), + (ActorMatchMin::NoMatch, true) + ); + // a group given by its id + assert_eq!( + actor_matches(&cred, &my_group, SYSTEM_LOOKUP), + (ActorMatchMin::GroupMatch(1), false) + ); + // the failure of one actor is the failure of the role, even when another one matches + let actors = format!( + r#"[{{"type": "user", "id": {}}}, {{"type": "group", "groups": ["staff"]}}]"#, + getuid().as_raw() + ); + let failing_groups = ActorLookup { + user: SYSTEM_LOOKUP.user, + group: failing_group_lookup, + }; + let result = ActorsFinderDeserializer { + cred: &cred, + lookup: failing_groups, + } + .deserialize(&mut serde_json::Deserializer::from_str(&actors)) + .unwrap(); + assert_eq!(result, (ActorMatchMin::UserMatch, true)); } #[test] @@ -430,6 +552,7 @@ mod test { .user() .unwrap() .build(), + lookup: SYSTEM_LOOKUP, }; let result = actors.deserialize(&mut serde_json::Deserializer::from_str(int)); assert!(result.is_err(), "Expected error, got: {result:?}"); diff --git a/src/dosr/finder/mod.rs b/src/dosr/finder/mod.rs index fb59479c..8b203c1b 100644 --- a/src/dosr/finder/mod.rs +++ b/src/dosr/finder/mod.rs @@ -1422,6 +1422,86 @@ mod tests { )); } + /// The role chosen for `cmd`, after a failed lookup of the actors of the roles `failed`. + #[cfg(feature = "ssd")] + fn choose_after_failed_lookups(roles: &Value, cmd: &str, failed: &[&str]) -> SrResult { + api::register_plugins(); + let policy = serde_json::json!({ "roles": roles }).to_string(); + let cli = Cli::builder().cmd_path(cmd.to_string()).build(); + let cred = dummy_cred(); + let mut data = super::de::ConfigFinderDeserializer { + cli: &cli, + cred: &cred, + } + .deserialize(&mut serde_json::Deserializer::from_str(&policy)) + .expect("valid policy"); + for role in &mut data.roles { + role.lookup_failed = failed.contains(&role.role.as_ref()); + } + BestExecSettings::retrieve_settings(&cli, &cred, &data, vec![("A", "B")], &["/usr/bin"]) + .map(|settings| settings.role) + } + + #[cfg(feature = "ssd")] + #[test] + fn test_a_failed_lookup_disables_the_roles_with_ssd() { + let none = serde_json::json!({}); + let roles = serde_json::json!([ + role_json( + "dev", + true, + Some("/usr/bin/ls"), + &serde_json::json!({"ssd": ["auditor"]}) + ), + role_json("auditor", false, None, &none), + role_json("ops", true, Some("/usr/bin/cat"), &none), + role_json("other", false, None, &none), + ]); + assert_eq!( + choose_after_failed_lookups(&roles, "/usr/bin/ls", &[]).unwrap(), + "dev" + ); + // whatever role the failed lookup belongs to, the roles with an ssd are disabled + for failed in ["auditor", "other", "dev"] { + assert!( + matches!( + choose_after_failed_lookups(&roles, "/usr/bin/ls", &[failed]), + Err(SrError::PermissionDenied) + ), + "{failed}" + ); + } + // the roles without one are not + assert_eq!( + choose_after_failed_lookups(&roles, "/usr/bin/cat", &["other"]).unwrap(), + "ops" + ); + } + + #[cfg(all(feature = "hierarchy", feature = "ssd"))] + #[test] + fn test_a_failed_lookup_disables_the_parents_of_a_role_with_ssd() { + let none = serde_json::json!({}); + let roles = serde_json::json!([ + role_json( + "senior", + true, + None, + &serde_json::json!({"parents": ["junior"], "ssd": ["auditor"]}) + ), + role_json("junior", false, Some("/usr/bin/ls"), &none), + role_json("auditor", false, None, &none), + ]); + assert_eq!( + choose_after_failed_lookups(&roles, "/usr/bin/ls", &[]).unwrap(), + "junior" + ); + assert!(matches!( + choose_after_failed_lookups(&roles, "/usr/bin/ls", &["auditor"]), + Err(SrError::PermissionDenied) + )); + } + #[test] fn test_default_policy_runs_as_the_requested_user() { api::register_plugins(); From 84e06b3b9f049c9b96d4a20a03f1c3528e1cabc4 Mon Sep 17 00:00:00 2001 From: LeChatP Date: Fri, 23 Oct 2026 21:11:10 +0200 Subject: [PATCH 22/88] tests: end-to-end check of a failed group lookup under SSD * tests: e2e.sh adds a role with an ssd and a role held through a group written by name; once the group has 120000 members, more than the 1 MiB of the lookups of dosr, getgrnam_r fails with ERANGE and the role with the ssd is refused; /etc/group and /etc/gshadow are restored, at the end of the check and in the cleanup Co-Authored-By: Claude --- tests/vagrant/e2e.sh | 53 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/tests/vagrant/e2e.sh b/tests/vagrant/e2e.sh index 7f5ddc14..2b5892d9 100755 --- a/tests/vagrant/e2e.sh +++ b/tests/vagrant/e2e.sh @@ -14,6 +14,9 @@ UNIT=rar-e2e PAM_SERVICE=/etc/pam.d/dosr PAM_SERVICE_BACKUP=/etc/pam.d/.dosr.rar-e2e PAM_ENV_FILE=/etc/rar-e2e.env +BIG_GROUP=rar-e2e-big +GROUP_BACKUP=/etc/.group.rar-e2e +GSHADOW_BACKUP=/etc/.gshadow.rar-e2e FAILED=0 # 0: pass, 77: skip, anything else: fail @@ -113,6 +116,15 @@ setup() { "commands": ["/usr/bin/true"] } ] + }, { + "name": "e2e-guarded", + "actors": [{ "user": "$TEST_USER" }], + "ssd": ["e2e-other"], + "tasks": [{ "name": "uid", "commands": ["/usr/bin/id ^-u$"] }] + }, { + "name": "e2e-other", + "actors": [{ "group": "$BIG_GROUP" }], + "tasks": [] }] } EOF @@ -127,8 +139,19 @@ restore_pam_service() { rm -f "$PAM_ENV_FILE" } +# /etc/group and /etc/gshadow as they were before ssd_role_disabled_by_a_failed_lookup +restore_groups() { + if [ -e "$GROUP_BACKUP" ]; then + mv -f "$GROUP_BACKUP" /etc/group + fi + if [ -e "$GSHADOW_BACKUP" ]; then + mv -f "$GSHADOW_BACKUP" /etc/gshadow + fi +} + cleanup() { restore_pam_service + restore_groups systemctl stop "$UNIT" 2>/dev/null rm -f "/etc/systemd/system/$UNIT.service" systemctl daemon-reload @@ -257,6 +280,35 @@ password_is_asked_again() { [ "$(grep -c 'Sorry, try again.' <<<"$out")" -eq 2 ] } +# a failed lookup of an actor disables the roles with a separation of duties: the members of +# a group take more than the 1 MiB that the lookups of dosr allow, getgrnam_r fails with ERANGE +ssd_role_disabled_by_a_failed_lookup() { + local uid before after members since + uid=$(id -u "$TEST_USER") + cp -p /etc/group "$GROUP_BACKUP" && cp -p /etc/gshadow "$GSHADOW_BACKUP" || return 1 + groupadd "$BIG_GROUP" || { + restore_groups + return 1 + } + before=$(as_user dosr -r e2e-guarded -t uid /usr/bin/id -u 2>/dev/null) + # one argument is limited to 128 KiB: the member list goes through a file + members=$(mktemp) + seq -f 'rar%07g' 1 120000 | paste -sd, >"$members" + awk -F: -v OFS=: -v group="$BIG_GROUP" -v list="$members" \ + 'BEGIN { getline all < list } $1 == group { $4 = all } { print }' \ + "$GROUP_BACKUP" >"$members.group" + cat "$members.group" >/etc/group + since=$(date +%s) + after=$(as_user dosr -r e2e-guarded -t uid /usr/bin/id -u 2>/dev/null) + rm -f "$members" "$members.group" + restore_groups + [ "$before" = "$uid" ] && [ "$after" != "$uid" ] || return 1 + # the refusal comes from the lookup, when the journal tells + if command -v journalctl >/dev/null; then + journalctl -q --since "@$since" | grep -q "Cannot look up the group $BIG_GROUP" + fi +} + trap cleanup EXIT setup check "the policy, its directory included, is immutable" policy_is_immutable @@ -269,4 +321,5 @@ check "CAP_NET_BIND_SERVICE is granted" capability_is_granted check "the bounding set is checked before running the command" bounding_set_is_checked_first check "the variables of pam_env go through the env filter of the task" pam_env_is_filtered check "a wrong password is asked again, up to the tries of the policy" password_is_asked_again +check "a failed lookup of an actor disables the roles with ssd" ssd_role_disabled_by_a_failed_lookup exit "$FAILED" From b343c8cffe5156541d816337c070da1c3d28bd33 Mon Sep 17 00:00:00 2001 From: LeChatP Date: Fri, 23 Oct 2026 21:14:58 +0200 Subject: [PATCH 23/88] fix: chsr refuses a duration too long instead of writing a wrong one or panicking * fix: chsr options timeout set --duration read a field that does not fit as 0 and ended in a panic when adding the fields overflowed: 153722867280912930:59:59 gave 00:59:59; a duration is now computed with checked arithmetic, and one that does not fit is an error * tests: the forms ss, mm:ss and hh:mm:ss, and durations too long, alone and through the timeout command Co-Authored-By: Claude --- src/chsr/cli/pair.rs | 72 ++++++++++++++++++++++++++++---------------- 1 file changed, 46 insertions(+), 26 deletions(-) diff --git a/src/chsr/cli/pair.rs b/src/chsr/cli/pair.rs index 6137ed5b..dff8cdb9 100644 --- a/src/chsr/cli/pair.rs +++ b/src/chsr/cli/pair.rs @@ -36,6 +36,22 @@ pub fn recurse_pair(pair: Pair, inputs: &mut Inputs) -> Result<(), Box Result> { + let mut seconds: i64 = 0; + for field in time.split(':') { + let value: i64 = field + .parse() + .map_err(|_| format!("Invalid duration: {time}"))?; + seconds = seconds + .checked_mul(60) + .and_then(|seconds| seconds.checked_add(value)) + .ok_or_else(|| format!("Duration too long: {time}"))?; + } + Ok(SignedDuration::from_secs(seconds)) +} + #[allow(clippy::too_many_lines)] fn match_pair(pair: &Pair, inputs: &mut Inputs) -> Result<(), Box> { match pair.as_rule() { @@ -174,31 +190,7 @@ fn match_pair(pair: &Pair, inputs: &mut Inputs) -> Result<(), Box { - let mut reversed = pair.as_str().split(':').rev(); - let mut duration: SignedDuration = SignedDuration::from_secs( - reversed - .next() - .ok_or("No seconds in time")? - .parse::() - .unwrap_or(0), - ); - if let Some(mins) = reversed.next() { - duration = duration - .checked_add( - SignedDuration::try_from_mins(mins.parse::().unwrap_or(0)) - .unwrap_or_default(), - ) - .expect("Invalid minutes"); - if let Some(hours) = reversed.next() { - duration = duration - .checked_add( - SignedDuration::try_from_hours(hours.parse::().unwrap_or(0)) - .unwrap_or_default(), - ) - .expect("Invalid hours"); - } - } - inputs.timeout_duration = Some(duration); + inputs.timeout_duration = Some(parse_time(pair.as_str())?); } Rule::opt_timeout_type => { if pair.as_str() == "tty" { @@ -506,7 +498,8 @@ mod test { util::{BOLD, RED, RST}, }; - use super::Inputs; + use super::{Inputs, parse_time}; + use jiff::SignedDuration; fn make_args(args: &str) -> String { shell_words::join(shell_words::split(args).expect("Failed to split args")) @@ -581,6 +574,33 @@ mod test { assert_eq!(inputs.role_type, Some(RoleType::Tasks)); } + #[test] + fn test_parse_time() { + assert_eq!(parse_time("5").unwrap(), SignedDuration::from_secs(5)); + assert_eq!(parse_time("5:00").unwrap(), SignedDuration::from_secs(300)); + assert_eq!( + parse_time("01:02:03").unwrap(), + SignedDuration::from_secs(3723) + ); + // too long: an error, never 0 nor a panic + assert!(parse_time("99999999999999999999").is_err()); + assert!(parse_time("153722867280912930:59:59").is_err()); + } + + #[test] + fn test_timeout_duration_too_long_is_an_error() { + let binding = make_args("o timeout set --duration 153722867280912930:59:59"); + let pairs = Cli::parse(Rule::cli, &binding).unwrap(); + let mut inputs = Inputs::default(); + assert!( + pairs + .into_iter() + .try_for_each(|pair| recurse_pair(pair, &mut inputs)) + .is_err() + ); + assert_eq!(inputs.timeout_duration, None); + } + #[test] fn test_list_role_all() { let inputs = get_inputs("r r1 l all"); From 52b215be106d21c499bcbec4e00b6affda96e72b Mon Sep 17 00:00:00 2001 From: LeChatP Date: Fri, 23 Oct 2026 21:17:53 +0200 Subject: [PATCH 24/88] feat: chsr edits the password option at each level * feat: chsr [role R [task T]] options password set [--tries N] [--delay hh:mm:ss] and unset [--tries] [--delay], and options show password * feat: set changes the fields given and keeps the others; unset removes the fields named, then the option once nothing is left in it; the fields that this version does not know are kept * feat: tries is 1 to 255, a delay that does not fit is refused, and set without a value is an error * tests: each level, field by field, the unknown fields, the refused values, the usage, and the saved file read back Co-Authored-By: Claude --- core/src/database/options.rs | 1 + src/chsr/cli/cli.pest | 14 +++++- src/chsr/cli/data.rs | 12 ++++- src/chsr/cli/mod.rs | 87 ++++++++++++++++++++++++++++++++++++ src/chsr/cli/pair.rs | 28 +++++++++++- src/chsr/cli/process.rs | 34 +++++++++++++- src/chsr/cli/process/json.rs | 75 +++++++++++++++++++++++++++++-- src/chsr/cli/usage.rs | 57 +++++++++++++++++++++++ 8 files changed, 297 insertions(+), 11 deletions(-) diff --git a/core/src/database/options.rs b/core/src/database/options.rs index 54b434c7..b403f629 100644 --- a/core/src/database/options.rs +++ b/core/src/database/options.rs @@ -57,6 +57,7 @@ pub enum OptType { Root, Bounding, Timeout, + Password, Authentication, ExecInfo, UMask, diff --git a/src/chsr/cli/cli.pest b/src/chsr/cli/cli.pest index 10af03b3..228abce8 100644 --- a/src/chsr/cli/cli.pest +++ b/src/chsr/cli/cli.pest @@ -168,11 +168,14 @@ caps_listing = { (whitelist | blacklist) ~ ((add | del | set) ~ capabilities // chsr o timeout set --type tty --duration 5:00 --max_usage 1 // chsr o t unset --type --duration --max_usage +// chsr o password set --tries 3 --delay 00:00:05 +// chsr o password unset --tries --delay + options_operations = { ("options" | "o") ~ opt_args } -opt_args = _{ opt_show | opt_path | opt_workdir | opt_env | opt_root | opt_bounding | opt_timeout | opt_skip_auth | opt_execinfo | opt_mask } +opt_args = _{ opt_show | opt_path | opt_workdir | opt_env | opt_root | opt_bounding | opt_timeout | opt_password | opt_skip_auth | opt_execinfo | opt_mask } opt_show = _{ list ~ opt_show_arg? } -opt_show_arg = { "all" | "cmd" | "cred" | "path" | "env" | "root" | "bounding" | "timeout" } +opt_show_arg = { "all" | "cmd" | "cred" | "path" | "env" | "root" | "bounding" | "timeout" | "password" } opt_path = { "path" ~ (opt_path_args | help) } opt_path_args = _{ opt_path_setpolicy | opt_path_set | opt_path_listing } @@ -243,6 +246,13 @@ seconds = _{ ASCII_DIGIT+ } opt_timeout_m_arg = { ("--max-usage" ~ assignment | "-m" ) ~ opt_timeout_max_usage? } opt_timeout_max_usage = { ASCII_DIGIT+ } +opt_password = { "password" ~ (opt_password_operations | help) } +opt_password_operations = { (set | del) ~ opt_password_args } +opt_password_args = _{ opt_password_t_arg ~ opt_password_d_arg? | opt_password_d_arg ~ opt_password_t_arg? } +opt_password_t_arg = { ("--tries" ~ assignment | "-t") ~ opt_password_tries? } +opt_password_tries = @{ ASCII_DIGIT+ } +opt_password_d_arg = { ("--delay" ~ assignment | "-d") ~ opt_password_delay? } +opt_password_delay = @{ ASCII_DIGIT+ ~ (":" ~ ASCII_DIGIT+){0,2} } assignment = _{ "=" | WHITESPACE*} help = { "-h" | "--help" } diff --git a/src/chsr/cli/data.rs b/src/chsr/cli/data.rs index 8f9762f0..8caecd61 100644 --- a/src/chsr/cli/data.rs +++ b/src/chsr/cli/data.rs @@ -1,4 +1,4 @@ -use std::{collections::HashMap, path::PathBuf}; +use std::{collections::HashMap, num::NonZeroU8, path::PathBuf}; use bon::Builder; use capctl::CapSet; @@ -58,6 +58,13 @@ pub enum SetListType { Set, } +#[derive(Debug, PartialEq, Eq, Clone, Copy)] +#[repr(usize)] +pub enum PasswordOpt { + Tries = 0, + Delay, +} + #[derive(Debug, PartialEq, Eq, Clone, Copy)] #[repr(usize)] pub enum TimeoutOpt { @@ -78,6 +85,9 @@ pub struct Inputs { pub timeout_type: Option, pub timeout_duration: Option, pub timeout_max_usage: Option, + pub password_arg: Option<[bool; 2]>, + pub password_tries: Option, + pub password_delay: Option, pub policy: bool, pub policy_path: Option, pub role_id: Option, diff --git a/src/chsr/cli/mod.rs b/src/chsr/cli/mod.rs index 7505a4d2..639e6724 100644 --- a/src/chsr/cli/mod.rs +++ b/src/chsr/cli/mod.rs @@ -993,6 +993,93 @@ mod tests { assert!(usage_without_policy(["upgrade"]).is_none()); } + /// The password option of `level`, as (tries, delay in seconds, unknown fields). + fn password(ctx: &TestContext, level: Level) -> Option<(Option, Option, usize)> { + ctx.opt(level) + .as_ref() + .borrow() + .password + .as_ref() + .map(|password| { + ( + password.tries.map(std::num::NonZeroU8::get), + password.delay.map(|delay| delay.as_secs()), + password.extra_fields.len(), + ) + }) + } + + #[test] + fn test_options_password() { + let name = "options_password"; + let (mut ctx, _defer) = TestContext::new(name); + // at each level; set changes the fields given and keeps the others + ctx.assert_command_success("o password set --tries 5 --delay 00:00:10"); + ctx.assert_command_success("r complete o password set --tries=4"); + ctx.assert_command_success("r complete t t_complete o password set -d 2"); + assert_eq!(password(&ctx, Level::Global), Some((Some(5), Some(10), 0))); + assert_eq!(password(&ctx, Level::Role), Some((Some(4), None, 0))); + assert_eq!(password(&ctx, Level::Task), Some((None, Some(2), 0))); + ctx.assert_command_success("o password set --delay 1:00"); + assert_eq!(password(&ctx, Level::Global), Some((Some(5), Some(60), 0))); + // unset removes the fields named, then the option once nothing is left + ctx.assert_command_success("o password unset --tries"); + assert_eq!(password(&ctx, Level::Global), Some((None, Some(60), 0))); + ctx.assert_command_success("o password unset -d"); + assert_eq!(password(&ctx, Level::Global), None); + // a field that this version does not know is kept + ctx.opt(Level::Role) + .as_ref() + .borrow_mut() + .password + .as_mut() + .unwrap() + .extra_fields + .insert("future".into(), Value::from(1)); + ctx.assert_command_success("r complete o password unset --tries"); + assert_eq!(password(&ctx, Level::Role), Some((None, None, 1))); + ctx.assert_command_success("r complete o password set --tries 4"); + assert_eq!(password(&ctx, Level::Role), Some((Some(4), None, 1))); + // refused, without a change + for command in [ + "o password set --tries 0", + "o password set --tries 256", + "o password set --tries", + "o password set --delay", + "o password set --delay 153722867280912930:59:59", + ] { + assert!(ctx.run_command(command).is_err(), "{command}"); + } + assert_eq!(password(&ctx, Level::Global), None); + ctx.assert_command_no_change("r complete t t_complete o show password"); + // what is saved is what dosr reads + ctx.settings.save_all().unwrap(); + let saved: Value = + serde_json::from_str(&fs::read_to_string(format!("{RAR_CFG_PATH}.{name}")).unwrap()) + .unwrap(); + let role = saved["roles"] + .as_array() + .unwrap() + .iter() + .find(|role| role["name"] == "complete") + .unwrap(); + assert_eq!( + role["options"]["password"], + serde_json::json!({"tries": 4, "future": 1}) + ); + let task = role["tasks"] + .as_array() + .unwrap() + .iter() + .find(|task| task["name"] == "t_complete") + .unwrap(); + assert_eq!( + task["options"]["password"], + serde_json::json!({"delay": "00:00:02"}) + ); + assert!(saved["options"].get("password").is_none()); + } + #[test] fn test_all_main() { let (mut ctx, _defer) = TestContext::new("all_main"); diff --git a/src/chsr/cli/pair.rs b/src/chsr/cli/pair.rs index dff8cdb9..bc3ea176 100644 --- a/src/chsr/cli/pair.rs +++ b/src/chsr/cli/pair.rs @@ -1,4 +1,4 @@ -use std::{collections::HashMap, error::Error, str::FromStr}; +use std::{collections::HashMap, error::Error, num::NonZeroU8, str::FromStr}; use capctl::Cap; use indexmap::IndexSet; @@ -16,7 +16,7 @@ use rootasrole_core::{ util::StorageMethod, }; -use super::data::{InputAction, Inputs, Rule, SetListType, TimeoutOpt}; +use super::data::{InputAction, Inputs, PasswordOpt, Rule, SetListType, TimeoutOpt}; type MatchingFunction = dyn Fn(&Pair, &mut Inputs) -> Result<(), Box>; @@ -221,6 +221,28 @@ fn match_pair(pair: &Pair, inputs: &mut Inputs) -> Result<(), Box { inputs.timeout_max_usage = Some(pair.as_str().parse::()?); } + Rule::opt_password_t_arg => { + let mut password_arg = inputs.password_arg.unwrap_or_default(); + password_arg[PasswordOpt::Tries as usize] = true; + inputs.password_arg.replace(password_arg); + } + Rule::opt_password_d_arg => { + let mut password_arg = inputs.password_arg.unwrap_or_default(); + password_arg[PasswordOpt::Delay as usize] = true; + inputs.password_arg.replace(password_arg); + } + Rule::opt_password_tries => { + let tries = pair + .as_str() + .parse::() + .ok() + .and_then(NonZeroU8::new) + .ok_or_else(|| format!("The tries must be 1 to 255: {}", pair.as_str()))?; + inputs.password_tries = Some(tries); + } + Rule::opt_password_delay => { + inputs.password_delay = Some(parse_time(pair.as_str())?); + } // === file === Rule::file => { inputs.policy = true; @@ -385,6 +407,8 @@ fn match_pair(pair: &Pair, inputs: &mut Inputs) -> Result<(), Box Result unset_password(rconfig, role_id.as_ref(), task_id, password_arg), + + Inputs { + // chsr o password set --tries 3 --delay 00:00:05 + action: InputAction::Set, + role_id, + task_id, + options: true, + password_arg: Some(password_arg), + password_tries, + password_delay, + .. + } => set_password( + rconfig, + role_id.as_ref(), + task_id, + password_arg, + password_tries, + password_delay, + ), + Inputs { // chsr o timeout unset --type --duration --max-usage action: InputAction::Del, diff --git a/src/chsr/cli/process/json.rs b/src/chsr/cli/process/json.rs index 9928624d..e9bfd905 100644 --- a/src/chsr/cli/process/json.rs +++ b/src/chsr/cli/process/json.rs @@ -1,16 +1,16 @@ use rootasrole_core::println_ignore_io_error; -use std::{cell::RefCell, collections::HashMap, error::Error, rc::Rc}; +use std::{cell::RefCell, collections::HashMap, error::Error, num::NonZeroU8, rc::Rc}; use indexmap::IndexSet; use jiff::SignedDuration; use log::{debug, warn}; -use crate::cli::data::{InputAction, RoleType, SetListType, TaskType, TimeoutOpt}; +use crate::cli::data::{InputAction, PasswordOpt, RoleType, SetListType, TaskType, TimeoutOpt}; use rootasrole_core::database::{ options::{ - EnvBehavior, EnvKey, Opt, OptStack, OptType, PathBehavior, SEnvOptions, SPathOptions, - STimeout, SUMask, SWorkdirEither, SWorkdirSet, WorkdirBehavior, + EnvBehavior, EnvKey, Opt, OptStack, OptType, PathBehavior, SEnvOptions, SPassword, + SPathOptions, STimeout, SUMask, SWorkdirEither, SWorkdirSet, WorkdirBehavior, }, structs::{ IdTask, RoleGetter, SCapabilities, SCommand, SGroupsEither, SRole, STask, SUserEither, @@ -92,6 +92,12 @@ fn list_task( serde_json::to_string_pretty(&opt.timeout)? ); } + OptType::Password => { + println_ignore_io_error!( + "{}", + serde_json::to_string_pretty(&opt.password)? + ); + } OptType::Authentication => { println_ignore_io_error!( "{}", @@ -815,6 +821,67 @@ pub fn env_whitelist_set( Ok(true) } +/// `chsr o password set`: the fields given are changed, the others and the unknown fields of the +/// option are kept. +pub fn set_password( + rconfig: &Rc>, + role_id: Option<&String>, + task_id: Option, + password_arg: [bool; 2], + tries: Option, + delay: Option, +) -> Result> { + debug!("chsr o password set"); + if password_arg[PasswordOpt::Tries as usize] && tries.is_none() { + return Err("--tries needs a number of tries".into()); + } + if password_arg[PasswordOpt::Delay as usize] && delay.is_none() { + return Err("--delay needs a duration".into()); + } + perform_on_target_opt(rconfig, role_id, task_id, |opt: Rc>| { + let mut opt = opt.as_ref().borrow_mut(); + let password = opt.password.get_or_insert_with(SPassword::default); + if let Some(tries) = tries { + password.tries = Some(tries); + } + if let Some(delay) = delay { + password.delay = Some(delay); + } + Ok(()) + })?; + Ok(true) +} + +/// `chsr o password unset`: the fields named are removed, then the option once nothing is left +/// in it. +pub fn unset_password( + rconfig: &Rc>, + role_id: Option<&String>, + task_id: Option, + password_arg: [bool; 2], +) -> Result> { + debug!("chsr o password unset"); + perform_on_target_opt(rconfig, role_id, task_id, |opt: Rc>| { + let mut opt = opt.as_ref().borrow_mut(); + if let Some(password) = opt.password.as_mut() { + if password_arg[PasswordOpt::Tries as usize] { + password.tries = None; + } + if password_arg[PasswordOpt::Delay as usize] { + password.delay = None; + } + if password.tries.is_none() + && password.delay.is_none() + && password.extra_fields.is_empty() + { + opt.password = None; + } + } + Ok(()) + })?; + Ok(true) +} + pub fn unset_timeout( rconfig: &Rc>, role_id: Option<&String>, diff --git a/src/chsr/cli/usage.rs b/src/chsr/cli/usage.rs index 8b53e78a..b7f0c7c8 100644 --- a/src/chsr/cli/usage.rs +++ b/src/chsr/cli/usage.rs @@ -43,6 +43,7 @@ enum UsageSectionId { OptionsWorkdir, OptionsEnv, OptionsTimeout, + OptionsPassword, OptionsAuth, OptionsExecinfo, OptionsUmask, @@ -272,6 +273,11 @@ const USAGE_OPTIONS_GENERAL: UsageSection = UsageSection { description: "Manage timeout settings (set, unset).", indent: 1, }, + UsageItem { + name: "password", + description: "Manage the password tries and the delay after a wrong one (set, unset).", + indent: 1, + }, UsageItem { name: "authentication [policy]", description: "Defines if user needs to authenticate (unset, skip, perform, inherit).", @@ -432,6 +438,32 @@ const USAGE_OPTIONS_TIMEOUT: UsageSection = UsageSection { examples: &["chsr options timeout set --type tty --duration 00:05:00 --max-usage 3"], }; +const USAGE_OPTIONS_PASSWORD: UsageSection = UsageSection { + title: "Password options", + synopsis: &["chsr options password [operation]"], + items: &[ + UsageItem { + name: "set, unset", + description: "Set or unset the password settings.", + indent: 1, + }, + UsageItem { + name: "--tries [1-255]", + description: "How many times the password is asked.", + indent: 2, + }, + UsageItem { + name: "--delay [HH:MM:SS]", + description: "How long to wait after a wrong password.", + indent: 2, + }, + ], + examples: &[ + "chsr options password set --tries 3 --delay 00:00:05", + "chsr role admin options password unset --delay", + ], +}; + const USAGE_OPTIONS_AUTH: UsageSection = UsageSection { title: "Authentication options", synopsis: &["chsr options authentication [policy]"], @@ -574,6 +606,7 @@ const fn usage_section(id: UsageSectionId) -> &'static UsageSection { UsageSectionId::OptionsWorkdir => &USAGE_OPTIONS_WORKDIR, UsageSectionId::OptionsEnv => &USAGE_OPTIONS_ENV, UsageSectionId::OptionsTimeout => &USAGE_OPTIONS_TIMEOUT, + UsageSectionId::OptionsPassword => &USAGE_OPTIONS_PASSWORD, UsageSectionId::OptionsAuth => &USAGE_OPTIONS_AUTH, UsageSectionId::OptionsExecinfo => &USAGE_OPTIONS_EXECINFO, UsageSectionId::OptionsUmask => &USAGE_OPTIONS_UMASK, @@ -654,6 +687,9 @@ fn rule_to_string(rule: Rule) -> String { Rule::opt_timeout_d_arg => "--duration (hh:mm:ss)", Rule::opt_timeout_t_arg => "--type (tty, ppid, uid)", Rule::opt_timeout_m_arg => "--max-usage (\\d+)", + Rule::opt_password_operations => "password set/unset operations", + Rule::opt_password_t_arg => "--tries (1-255)", + Rule::opt_password_d_arg => "--delay (hh:mm:ss)", Rule::roles_operations => "roles list/purge/add/del operations or existing role name", Rule::role_type_arg => "all, actors or tasks", Rule::role_grant_revoke => "grant, revoke", @@ -673,6 +709,7 @@ fn rule_to_string(rule: Rule) -> String { Rule::cred_caps_operations => "caps", Rule::list => "show, list, l", Rule::opt_timeout => "timeout", + Rule::opt_password => "password", Rule::opt_path | Rule::path => "path", Rule::opt_env => "env", Rule::opt_workdir => "workdir", @@ -725,6 +762,7 @@ const OPTIONS_AUTH_SECTIONS: &[UsageSectionId] = &[UsageSectionId::OptionsAuth]; const OPTIONS_EXECINFO_SECTIONS: &[UsageSectionId] = &[UsageSectionId::OptionsExecinfo]; const OPTIONS_UMASK_SECTIONS: &[UsageSectionId] = &[UsageSectionId::OptionsUmask]; const OPTIONS_TIMEOUT_SECTIONS: &[UsageSectionId] = &[UsageSectionId::OptionsTimeout]; +const OPTIONS_PASSWORD_SECTIONS: &[UsageSectionId] = &[UsageSectionId::OptionsPassword]; const OPTIONS_PATH_SECTIONS: &[UsageSectionId] = &[UsageSectionId::OptionsPath]; const OPTIONS_WORKDIR_SECTIONS: &[UsageSectionId] = &[UsageSectionId::OptionsWorkdir]; const OPTIONS_ENV_SECTIONS: &[UsageSectionId] = &[UsageSectionId::OptionsEnv]; @@ -757,6 +795,10 @@ const fn usage_sections_for_rule(rule: Rule) -> Option<&'static [UsageSectionId] | Rule::opt_timeout_d_arg | Rule::opt_timeout_t_arg | Rule::opt_timeout_m_arg => Some(OPTIONS_TIMEOUT_SECTIONS), + Rule::opt_password + | Rule::opt_password_operations + | Rule::opt_password_t_arg + | Rule::opt_password_d_arg => Some(OPTIONS_PASSWORD_SECTIONS), Rule::opt_path | Rule::opt_path_args | Rule::opt_path_set @@ -834,6 +876,21 @@ mod tests { ); } + #[test] + fn maps_password_rules_to_password_section() { + for rule in [ + Rule::opt_password, + Rule::opt_password_operations, + Rule::opt_password_t_arg, + Rule::opt_password_d_arg, + ] { + assert_eq!( + usage_sections_for_rule(rule), + Some(OPTIONS_PASSWORD_SECTIONS) + ); + } + } + #[test] fn returns_none_for_unknown_rule() { assert_eq!(usage_sections_for_rule(Rule::EOI), None); From 5b4d5ede2fff11f39bce0d74bf6de017fb451d99 Mon Sep 17 00:00:00 2001 From: LeChatP Date: Fri, 23 Oct 2026 21:52:02 +0200 Subject: [PATCH 25/88] fix: chsr writes only what a command sets in the options of a role or task * fix: a role or task without options got Opt::default(), which holds every built-in default (env and path lists, bounding, authentication, timeout, password...): chsr role R options root user wrote them all at the role, where its lists replaced those of the upper levels; the options are now created empty, at their level * fix: whitelist, blacklist, checklist and setlist of path and env worked on a temporary when the level had no path or env option: chsr reported a success and saved nothing; they now edit the option, and removing from a list that the level does not define changes nothing * tests: the options of a new role and task, and list edits on a level without the option, read back from the saved file Co-Authored-By: Claude --- src/chsr/cli/mod.rs | 71 ++++++++++++++++++++++++++++++++++++ src/chsr/cli/process.rs | 23 +++++++++--- src/chsr/cli/process/json.rs | 16 ++++++-- 3 files changed, 100 insertions(+), 10 deletions(-) diff --git a/src/chsr/cli/mod.rs b/src/chsr/cli/mod.rs index 639e6724..f7282679 100644 --- a/src/chsr/cli/mod.rs +++ b/src/chsr/cli/mod.rs @@ -993,6 +993,77 @@ mod tests { assert!(usage_without_policy(["upgrade"]).is_none()); } + /// The policy as saved in the file of the test `name`. + fn saved(ctx: &mut TestContext, name: &str) -> Value { + ctx.settings.save_all().unwrap(); + serde_json::from_str(&fs::read_to_string(format!("{RAR_CFG_PATH}.{name}")).unwrap()) + .unwrap() + } + + fn saved_role<'v>(saved: &'v Value, role: &str) -> &'v Value { + saved["roles"] + .as_array() + .unwrap() + .iter() + .find(|r| r["name"] == role) + .unwrap() + } + + fn saved_task<'v>(saved: &'v Value, role: &str, task: &str) -> &'v Value { + saved_role(saved, role)["tasks"] + .as_array() + .unwrap() + .iter() + .find(|t| t["name"] == task) + .unwrap() + } + + #[test] + fn test_new_options_hold_only_what_is_set() { + let name = "new_options_hold_only_what_is_set"; + let (mut ctx, _defer) = TestContext::new(name); + ctx.settings.get_root().config.as_ref().borrow_mut().options = None; + ctx.assert_command_success("o bounding strict"); + ctx.assert_command_success("r fresh create"); + ctx.assert_command_success("r fresh o root user"); + ctx.assert_command_success("r fresh t t1 add"); + ctx.assert_command_success("r fresh t t1 o path whitelist add /opt/bin"); + let saved = saved(&mut ctx, name); + // the other options stay inherited, not written with the built-in defaults + assert_eq!(saved["options"], serde_json::json!({"bounding": "strict"})); + assert_eq!( + saved_role(&saved, "fresh")["options"], + serde_json::json!({"root": "user"}) + ); + assert_eq!( + saved_task(&saved, "fresh", "t1")["options"], + serde_json::json!({"path": {"add": ["/opt/bin"]}}) + ); + } + + #[test] + fn test_list_edits_on_a_level_without_the_option() { + let name = "list_edits_on_a_level_without_the_option"; + let (mut ctx, _defer) = TestContext::new(name); + ctx.assert_command_success("r fresh create"); + ctx.assert_command_success("r fresh t t1 add"); + ctx.assert_command_success("r fresh t t1 o env whitelist add MYVAR"); + ctx.assert_command_success("r fresh t t1 o env checklist add TZ"); + ctx.assert_command_success("r fresh o path blacklist add /tmp"); + // removing from a list that the level does not define changes nothing + ctx.assert_command_success("r fresh o env blacklist del LD_PRELOAD"); + ctx.assert_command_success("r fresh t t1 o path whitelist del /usr/bin"); + let saved = saved(&mut ctx, name); + assert_eq!( + saved_task(&saved, "fresh", "t1")["options"], + serde_json::json!({"env": {"keep": ["MYVAR"], "check": ["TZ"]}}) + ); + assert_eq!( + saved_role(&saved, "fresh")["options"], + serde_json::json!({"path": {"sub": ["/tmp"]}}) + ); + } + /// The password option of `level`, as (tries, delay in seconds, unknown fields). fn password(ctx: &TestContext, level: Level) -> Option<(Option, Option, usize)> { ctx.opt(level) diff --git a/src/chsr/cli/process.rs b/src/chsr/cli/process.rs index d25f9161..e68043da 100644 --- a/src/chsr/cli/process.rs +++ b/src/chsr/cli/process.rs @@ -16,7 +16,7 @@ use log::debug; use rootasrole_core::{ database::{ - options::{Opt, OptType}, + options::{Level, Opt, OptType}, structs::{IdTask, RoleGetter}, }, file::FileSettings, @@ -529,15 +529,18 @@ pub fn perform_on_target_opt( task_id: Option, exec_on_opt: impl Fn(Rc>) -> Result<(), Box>, ) -> Result<(), Box> { - // Helper function to execute on option or create a new one + /// Execute on the options of the level, created empty when the level has none: only what + /// the command sets is written, the other options stay inherited. `Opt::default()` would + /// write every built-in default at that level, which replaces the lists of the upper levels. fn execute_or_create_option( exec_on_opt: impl Fn(Rc>) -> Result<(), Box>, options: &mut Option>>, + level: Level, ) -> Result<(), Box> { if let Some(opt) = options { exec_on_opt(opt.clone()) } else { - let new_opt = Rc::new(RefCell::new(Opt::default())); + let new_opt = Rc::new(RefCell::new(Opt::builder(level).build())); let result = exec_on_opt(new_opt.clone()); *options = Some(new_opt); result @@ -552,12 +555,20 @@ pub fn perform_on_target_opt( // If task_id is provided, find the task if let Some(task_id) = task_id { let task = role_borrowed.task(&task_id).ok_or("Task not found")?; - return execute_or_create_option(exec_on_opt, &mut task.as_ref().borrow_mut().options); + return execute_or_create_option( + exec_on_opt, + &mut task.as_ref().borrow_mut().options, + Level::Task, + ); } // No task_id, use role options - return execute_or_create_option(exec_on_opt, &mut role_borrowed.options); + return execute_or_create_option(exec_on_opt, &mut role_borrowed.options, Level::Role); } // No role_id, use global config options - execute_or_create_option(exec_on_opt, &mut rconfig.as_ref().borrow_mut().options) + execute_or_create_option( + exec_on_opt, + &mut rconfig.as_ref().borrow_mut().options, + Level::Global, + ) } diff --git a/src/chsr/cli/process/json.rs b/src/chsr/cli/process/json.rs index e9bfd905..5e33a52d 100644 --- a/src/chsr/cli/process/json.rs +++ b/src/chsr/cli/process/json.rs @@ -947,9 +947,13 @@ pub fn path_setlist2( ) -> Result> { debug!("chsr o path set whitelist|blacklist add|del|set path1:path2:path3 22222222222"); perform_on_target_opt(rconfig, role_id, task_id, |opt: Rc>| { - let mut default_path = SPathOptions::default(); let mut binding = opt.as_ref().borrow_mut(); - let path = binding.path.as_mut().unwrap_or(&mut default_path); + // removing from a list that the level does not define changes nothing + if binding.path.is_none() && !matches!(action, InputAction::Add | InputAction::Set) { + warn!("No path option at this level, nothing to remove"); + return Ok(()); + } + let path = binding.path.get_or_insert_default(); match setlist_type { Some(SetListType::White) => match action { InputAction::Add => { @@ -1202,9 +1206,13 @@ pub fn env_setlist_add( ) -> Result> { debug!("chsr o path {setlist_type:?} {action:?} path1 path2 path3"); perform_on_target_opt(rconfig, role_id, task_id, move |opt: Rc>| { - let mut default_env = SEnvOptions::default(); let mut binding = opt.as_ref().borrow_mut(); - let env = binding.env.as_mut().unwrap_or(&mut default_env); + // removing from a list that the level does not define changes nothing + if binding.env.is_none() && !matches!(action, InputAction::Add | InputAction::Set) { + warn!("No env option at this level, nothing to remove"); + return Ok(()); + } + let env = binding.env.get_or_insert_default(); match setlist_type { Some(SetListType::White) => match action { From 72a7a784d20470a110fe68be3bd779b3f6cf6323 Mon Sep 17 00:00:00 2001 From: LeChatP Date: Fri, 23 Oct 2026 21:53:09 +0200 Subject: [PATCH 26/88] fix: chsr options timeout set and unset keep the fields they do not name * fix: set and unset started from an empty timeout: set --type tty dropped the duration and the max usage, unset --duration dropped the type, the max usage and the unknown fields, and left an empty object * fix: set changes the fields given, unset removes the fields named, then the option once nothing is left in it; set without a value is an error * tests: chsr options timeout, field by field, with an unknown field and a level without the option, read back from the saved file Co-Authored-By: Claude --- src/chsr/cli/mod.rs | 54 ++++++++++++++++++++++++++++++++++++ src/chsr/cli/process.rs | 3 +- src/chsr/cli/process/json.rs | 51 ++++++++++++++++++++++------------ 3 files changed, 90 insertions(+), 18 deletions(-) diff --git a/src/chsr/cli/mod.rs b/src/chsr/cli/mod.rs index f7282679..7b6ce492 100644 --- a/src/chsr/cli/mod.rs +++ b/src/chsr/cli/mod.rs @@ -1064,6 +1064,60 @@ mod tests { ); } + #[test] + fn test_options_timeout() { + let name = "options_timeout"; + let (mut ctx, _defer) = TestContext::new(name); + // set changes the fields given and keeps the others + ctx.assert_command_success("o timeout set --type tty"); + ctx.assert_command_success("o timeout set -d 5:00"); + assert_eq!( + saved(&mut ctx, name)["options"]["timeout"], + serde_json::json!({"type": "tty", "duration": "00:05:00", "max_usage": 1}) + ); + // unset removes the fields named, then the option once nothing is left + ctx.assert_command_success("o timeout unset --max-usage"); + assert_eq!( + saved(&mut ctx, name)["options"]["timeout"], + serde_json::json!({"type": "tty", "duration": "00:05:00"}) + ); + ctx.assert_command_success("o timeout unset --type --duration"); + assert!(saved(&mut ctx, name)["options"].get("timeout").is_none()); + // a field that this version does not know is kept + ctx.opt(Level::Role) + .as_ref() + .borrow_mut() + .timeout + .as_mut() + .unwrap() + .extra_fields + .insert("future".into(), Value::from(1)); + ctx.assert_command_success("r complete o timeout unset --type --duration --max-usage"); + ctx.assert_command_success("r complete o timeout set --max-usage 3"); + // a level without the option + ctx.assert_command_success("r fresh create"); + ctx.assert_command_success("r fresh o timeout set --type uid"); + // refused, without a change + for command in [ + "o timeout set --type", + "o timeout set --duration", + "o timeout set --max-usage", + ] { + assert!(ctx.run_command(command).is_err(), "{command}"); + } + ctx.assert_command_no_change("r complete t t_complete o show timeout"); + let saved = saved(&mut ctx, name); + assert!(saved["options"].get("timeout").is_none()); + assert_eq!( + saved_role(&saved, "complete")["options"]["timeout"], + serde_json::json!({"max_usage": 3, "future": 1}) + ); + assert_eq!( + saved_role(&saved, "fresh")["options"], + serde_json::json!({"timeout": {"type": "uid"}}) + ); + } + /// The password option of `level`, as (tries, delay in seconds, unknown fields). fn password(ctx: &TestContext, level: Level) -> Option<(Option, Option, usize)> { ctx.opt(level) diff --git a/src/chsr/cli/process.rs b/src/chsr/cli/process.rs index e68043da..0027e9b5 100644 --- a/src/chsr/cli/process.rs +++ b/src/chsr/cli/process.rs @@ -410,7 +410,7 @@ pub fn process_input(storage: &mut FileSettings, inputs: Inputs) -> Result Result>, role_id: Option<&String>, @@ -890,38 +892,54 @@ pub fn unset_timeout( ) -> Result> { debug!("chsr o timeout unset"); perform_on_target_opt(rconfig, role_id, task_id, |opt: Rc>| { - let mut timeout = STimeout::default(); - if timeout_arg[TimeoutOpt::Type as usize] { - timeout.type_field = None; - } - if timeout_arg[TimeoutOpt::Duration as usize] { - timeout.duration = None; - } - if timeout_arg[TimeoutOpt::MaxUsage as usize] { - timeout.max_usage = None; - } - if timeout_arg.iter().all(|b| *b) { - opt.as_ref().borrow_mut().timeout = None; - } else { - opt.as_ref().borrow_mut().timeout = Some(timeout); + let mut opt = opt.as_ref().borrow_mut(); + if let Some(timeout) = opt.timeout.as_mut() { + if timeout_arg[TimeoutOpt::Type as usize] { + timeout.type_field = None; + } + if timeout_arg[TimeoutOpt::Duration as usize] { + timeout.duration = None; + } + if timeout_arg[TimeoutOpt::MaxUsage as usize] { + timeout.max_usage = None; + } + if timeout.type_field.is_none() + && timeout.duration.is_none() + && timeout.max_usage.is_none() + && timeout.extra_fields.is_empty() + { + opt.timeout = None; + } } - Ok(()) })?; Ok(true) } +/// `chsr o timeout set`: the fields given are changed, the others and the unknown fields of the +/// option are kept. pub fn set_timeout( rconfig: &Rc>, role_id: Option<&String>, task_id: Option, + timeout_arg: [bool; 3], timeout_type: Option, timeout_duration: Option, timeout_max_usage: Option, ) -> Result> { debug!("chsr o timeout set"); + if timeout_arg[TimeoutOpt::Type as usize] && timeout_type.is_none() { + return Err("--type needs tty, ppid or uid".into()); + } + if timeout_arg[TimeoutOpt::Duration as usize] && timeout_duration.is_none() { + return Err("--duration needs a duration".into()); + } + if timeout_arg[TimeoutOpt::MaxUsage as usize] && timeout_max_usage.is_none() { + return Err("--max-usage needs a number".into()); + } perform_on_target_opt(rconfig, role_id, task_id, |opt: Rc>| { - let mut timeout = STimeout::default(); + let mut opt = opt.as_ref().borrow_mut(); + let timeout = opt.timeout.get_or_insert_with(STimeout::default); if let Some(timeout_type) = timeout_type { timeout.type_field = Some(timeout_type); } @@ -931,7 +949,6 @@ pub fn set_timeout( if let Some(max_usage) = timeout_max_usage { timeout.max_usage = Some(max_usage); } - opt.as_ref().borrow_mut().timeout = Some(timeout); Ok(()) })?; Ok(true) From 34a299d87935dc5f9b922b3f9065dff21451c818 Mon Sep 17 00:00:00 2001 From: LeChatP Date: Fri, 23 Oct 2026 21:54:30 +0200 Subject: [PATCH 27/88] fix: chsr accepts the variable names and patterns of the policy, and no others * fix: the grammar of chsr required two characters (o env whitelist add A was refused), refused the * of patterns (LC_* could not be added), but accepted - and non-ASCII letters, which EnvKey refuses: o env whitelist add MY-VAR panicked; a key now follows is_valid_env_pattern, and is built with EnvKey::new, an error instead of a panic * fix: the names of the set list are variable names, a * is refused there as the policy does * fix: a refused name says what is expected instead of "unknown rule" * tests: one letter, patterns, and the names that the policy refuses Co-Authored-By: Claude --- src/chsr/cli/cli.pest | 3 ++- src/chsr/cli/mod.rs | 25 +++++++++++++++++++++++++ src/chsr/cli/pair.rs | 12 +++++++++--- src/chsr/cli/usage.rs | 2 ++ 4 files changed, 38 insertions(+), 4 deletions(-) diff --git a/src/chsr/cli/cli.pest b/src/chsr/cli/cli.pest index 228abce8..e251c953 100644 --- a/src/chsr/cli/cli.pest +++ b/src/chsr/cli/cli.pest @@ -210,7 +210,8 @@ env_unquoted_value = { (!"," ~ !"\"" ~ ANY)* } env_char = _{ (!"\\" ~ !"\"") ~ ANY } env_escape = _{ "\\" ~ ("\"" | ",") } -env_key = @{ (CASED_LETTER | "_") ~ (CASED_LETTER | ASCII_DIGIT | "-" | "_")+ } +// a variable name, where `*` matches any characters (core::util::is_valid_env_pattern) +env_key = @{ (ASCII_ALPHA | "_" | "*") ~ (ASCII_ALPHANUMERIC | "_" | "*")* } opt_root = { "root" ~ (opt_root_args | help) } opt_root_args = { del | "privileged" | "user" } diff --git a/src/chsr/cli/mod.rs b/src/chsr/cli/mod.rs index 7b6ce492..fe241e91 100644 --- a/src/chsr/cli/mod.rs +++ b/src/chsr/cli/mod.rs @@ -1118,6 +1118,31 @@ mod tests { ); } + #[test] + fn test_options_env_names() { + let name = "options_env_names"; + let (mut ctx, _defer) = TestContext::new(name); + // one letter and patterns, like the policy accepts + ctx.assert_command_success("o env whitelist add A"); + ctx.assert_command_success("o env whitelist add LC_*,*_PROXY"); + ctx.assert_command_success("o env setlist add Z=1"); + // a name that the policy refuses is an error, not a panic + for command in [ + "o env whitelist add MY-VAR", + "o env whitelist add 1VAR", + "o env setlist add BAD*=1", + ] { + assert!(ctx.run_command(command).is_err(), "{command}"); + } + let saved = saved(&mut ctx, name); + assert_eq!( + saved["options"]["env"]["keep"], + serde_json::json!(["env1", "env2", "A", "LC_*", "*_PROXY"]) + ); + assert_eq!(saved["options"]["env"]["set"]["Z"], "1"); + assert!(saved["options"]["env"]["set"].get("BAD*").is_none()); + } + /// The password option of `level`, as (tries, delay in seconds, unknown fields). fn password(ctx: &TestContext, level: Level) -> Option<(Option, Option, usize)> { ctx.opt(level) diff --git a/src/chsr/cli/pair.rs b/src/chsr/cli/pair.rs index bc3ea176..db0e60ac 100644 --- a/src/chsr/cli/pair.rs +++ b/src/chsr/cli/pair.rs @@ -10,10 +10,10 @@ use crate::cli::data::{Convertion, RoleType, TaskType}; use rootasrole_core::{ database::{ actor::{SActor, SGroupType}, - options::{EnvBehavior, OptType, PathBehavior, TimestampType, WorkdirBehavior}, + options::{EnvBehavior, EnvKey, OptType, PathBehavior, TimestampType, WorkdirBehavior}, structs::{IdTask, SetBehavior}, }, - util::StorageMethod, + util::{StorageMethod, is_valid_env_name}, }; use super::data::{InputAction, Inputs, PasswordOpt, Rule, SetListType, TimeoutOpt}; @@ -439,12 +439,18 @@ fn match_pair(pair: &Pair, inputs: &mut Inputs) -> Result<(), Box { if let Some(options_env) = inputs.options_key_env.as_mut() { - options_env.insert(pair.as_str().into()); + options_env.insert(EnvKey::new(pair.as_str().to_string())?); } } Rule::opt_root_args => { diff --git a/src/chsr/cli/usage.rs b/src/chsr/cli/usage.rs index b7f0c7c8..7460273f 100644 --- a/src/chsr/cli/usage.rs +++ b/src/chsr/cli/usage.rs @@ -712,6 +712,8 @@ fn rule_to_string(rule: Rule) -> String { Rule::opt_password => "password", Rule::opt_path | Rule::path => "path", Rule::opt_env => "env", + Rule::env_key | Rule::env_key_list => "a variable name, where * matches any characters", + Rule::env_key_value | Rule::env_value_list => "NAME=value", Rule::opt_workdir => "workdir", Rule::opt_root => "root", Rule::opt_bounding => "bounding", From a5c6c09686d1125e5ff4e2c26819a36f115003ec Mon Sep 17 00:00:00 2001 From: LeChatP Date: Fri, 23 Oct 2026 21:57:30 +0200 Subject: [PATCH 28/88] fix: chsr shows the option asked for at every level, and turns its panics into errors * fix: options show only filtered at the task level: at the global and role levels it printed every option, the whole policy for the global level; it now shows the resolved options of the level, or the one asked for * fix: options show can name authentication (auth), execinfo (info), umask (mask), workdir and password; cmd and cred, which are not options, panicked in an unreachable! and are refused * fix: chsr role R task T purge on a task that does not exist panicked; it and the other unreachable! and expect of chsr outside the tests are errors * tests: every option name at every level, missing roles and tasks, and the purges that the grammar accepts Co-Authored-By: Claude --- src/chsr/cli/cli.pest | 3 +- src/chsr/cli/mod.rs | 55 +++++++- src/chsr/cli/pair.rs | 57 ++++----- src/chsr/cli/process.rs | 4 +- src/chsr/cli/process/convert.rs | 2 +- src/chsr/cli/process/json.rs | 214 +++++++++++++++----------------- 6 files changed, 183 insertions(+), 152 deletions(-) diff --git a/src/chsr/cli/cli.pest b/src/chsr/cli/cli.pest index e251c953..c2d6b2c7 100644 --- a/src/chsr/cli/cli.pest +++ b/src/chsr/cli/cli.pest @@ -175,7 +175,8 @@ options_operations = { ("options" | "o") ~ opt_args } opt_args = _{ opt_show | opt_path | opt_workdir | opt_env | opt_root | opt_bounding | opt_timeout | opt_password | opt_skip_auth | opt_execinfo | opt_mask } opt_show = _{ list ~ opt_show_arg? } -opt_show_arg = { "all" | "cmd" | "cred" | "path" | "env" | "root" | "bounding" | "timeout" | "password" } +// a longer name before its prefix: "authentication" before "auth" +opt_show_arg = { "all" | "path" | "env" | "root" | "bounding" | "timeout" | "password" | "authentication" | "auth" | "execinfo" | "info" | "umask" | "mask" | "workdir" } opt_path = { "path" ~ (opt_path_args | help) } opt_path_args = _{ opt_path_setpolicy | opt_path_set | opt_path_listing } diff --git a/src/chsr/cli/mod.rs b/src/chsr/cli/mod.rs index fe241e91..e1c7c3fa 100644 --- a/src/chsr/cli/mod.rs +++ b/src/chsr/cli/mod.rs @@ -83,7 +83,7 @@ where return start_editing(&path, &mut *file.data.data.as_ref().borrow_mut()); } return start_editing( - folder.expect("implementation error"), + folder.ok_or("the editor needs a temporary folder")?, &mut *storage.get_root_mut(), ); } @@ -1143,6 +1143,59 @@ mod tests { assert!(saved["options"]["env"]["set"].get("BAD*").is_none()); } + #[test] + fn test_options_show_every_option_at_every_level() { + let (mut ctx, _defer) = TestContext::new("options_show_every_option_at_every_level"); + for level in ["", "r complete ", "r complete t t_complete "] { + for option in [ + "", + "all", + "path", + "env", + "root", + "bounding", + "timeout", + "password", + "authentication", + "auth", + "execinfo", + "info", + "umask", + "mask", + "workdir", + ] { + ctx.assert_command_no_change(format!("{level}o show {option}").trim_end()); + } + // not options: refused, never a panic + for option in ["cmd", "cred"] { + assert!(ctx.run_command(&format!("{level}o show {option}")).is_err()); + } + } + } + + #[test] + fn test_missing_targets_are_errors() { + let (mut ctx, _defer) = TestContext::new("missing_targets_are_errors"); + for command in [ + "r complete t nope purge", + "r complete t nope show", + "r nope t t1 purge", + "r nope o root user", + "r complete t nope o root user", + ] { + assert!(ctx.run_command(command).is_err(), "{command}"); + } + // purges that the grammar accepts, whatever they do, never panic + for command in [ + "r complete t t_complete cmd whitelist purge", + "r complete t t_complete cred caps whitelist purge", + "o workdir blacklist purge", + "o env setlist purge", + ] { + let _ = ctx.run_command(command); + } + } + /// The password option of `level`, as (tries, delay in seconds, unknown fields). fn password(ctx: &TestContext, level: Level) -> Option<(Option, Option, usize)> { ctx.opt(level) diff --git a/src/chsr/cli/pair.rs b/src/chsr/cli/pair.rs index db0e60ac..a93e03ce 100644 --- a/src/chsr/cli/pair.rs +++ b/src/chsr/cli/pair.rs @@ -114,7 +114,7 @@ fn match_pair(pair: &Pair, inputs: &mut Inputs) -> Result<(), Box, inputs: &mut Inputs) -> Result<(), Box { inputs.convert_reconfigure = true; @@ -145,7 +145,7 @@ fn match_pair(pair: &Pair, inputs: &mut Inputs) -> Result<(), Box { @@ -155,7 +155,7 @@ fn match_pair(pair: &Pair, inputs: &mut Inputs) -> Result<(), Box { @@ -169,7 +169,7 @@ fn match_pair(pair: &Pair, inputs: &mut Inputs) -> Result<(), Box { @@ -181,7 +181,7 @@ fn match_pair(pair: &Pair, inputs: &mut Inputs) -> Result<(), Box { @@ -200,7 +200,7 @@ fn match_pair(pair: &Pair, inputs: &mut Inputs) -> Result<(), Box { @@ -262,7 +262,7 @@ fn match_pair(pair: &Pair, inputs: &mut Inputs) -> Result<(), Box, inputs: &mut Inputs) -> Result<(), Box, inputs: &mut Inputs) -> Result<(), Box { - if pair.as_str() == "all" { - inputs.options_type = None; - } else if pair.as_str() == "path" { - inputs.options_type = Some(OptType::Path); - } else if pair.as_str() == "env" { - inputs.options_type = Some(OptType::Env); - } else if pair.as_str() == "root" { - inputs.options_type = Some(OptType::Root); - } else if pair.as_str() == "bounding" { - inputs.options_type = Some(OptType::Bounding); - } else if pair.as_str() == "timeout" { - inputs.options_type = Some(OptType::Timeout); - } else if pair.as_str() == "password" { - inputs.options_type = Some(OptType::Password); - } else if pair.as_str() == "authentication" { - inputs.options_type = Some(OptType::Authentication); - } else if pair.as_str() == "execinfo" { - inputs.options_type = Some(OptType::ExecInfo); - } else if pair.as_str() == "umask" { - inputs.options_type = Some(OptType::UMask); - } else { - unreachable!("Unknown option type: {}", pair.as_str()) - } + inputs.options_type = match pair.as_str() { + "all" => None, + "path" => Some(OptType::Path), + "env" => Some(OptType::Env), + "root" => Some(OptType::Root), + "bounding" => Some(OptType::Bounding), + "timeout" => Some(OptType::Timeout), + "password" => Some(OptType::Password), + "authentication" | "auth" => Some(OptType::Authentication), + "execinfo" | "info" => Some(OptType::ExecInfo), + "umask" | "mask" => Some(OptType::UMask), + "workdir" => Some(OptType::Workdir), + other => return Err(format!("Unknown option: {other}").into()), + }; } Rule::path => { inputs.options_path = Some(pair.as_str().to_string()); @@ -489,7 +480,7 @@ fn match_pair(pair: &Pair, inputs: &mut Inputs) -> Result<(), Box Result { debug!("Convert from: {}", from.display()); - let from_type = convertion.from_type.expect("Impossible state"); + let from_type = convertion.from_type.ok_or("--from needs a type")?; if from == &convertion.to { error!("The source and destination paths are the same"); return Ok(false); diff --git a/src/chsr/cli/process/json.rs b/src/chsr/cli/process/json.rs index 0d991dca..9066920c 100644 --- a/src/chsr/cli/process/json.rs +++ b/src/chsr/cli/process/json.rs @@ -28,23 +28,42 @@ pub fn list_json( task_type: Option, role_type: Option, ) -> Result<(), Box> { - let config = rconfig.as_ref().borrow(); - role_id.map_or_else( - || { + let Some(role_id) = role_id else { + if options { + let rcopt = OptStack::from_roles(rconfig) + .to_opt() + .map_err(std::io::Error::other)?; println_ignore_io_error!( "{}", - serde_json::to_string_pretty(&*config) - .unwrap_or_else(|_| "Failed to serialize config".to_string()) + options_to_show(&rcopt.as_ref().borrow(), options_type)? ); - Ok(()) - }, - |role_id| { - rconfig.role(role_id).map_or_else( - || Err("Role not found".into()), - |role| list_task(task_id, &role, options, options_type, task_type, role_type), - ) - }, - ) + } else { + println_ignore_io_error!( + "{}", + serde_json::to_string_pretty(&*rconfig.as_ref().borrow())? + ); + } + return Ok(()); + }; + let role = rconfig.role(role_id).ok_or("Role not found")?; + list_task(task_id, &role, options, options_type, task_type, role_type) +} + +/// The options of a level, resolved with the upper levels, or only the one asked for. +fn options_to_show(opt: &Opt, options_type: Option) -> serde_json::Result { + match options_type { + None => serde_json::to_string_pretty(opt), + Some(OptType::Path) => serde_json::to_string_pretty(&opt.path), + Some(OptType::Env) => serde_json::to_string_pretty(&opt.env), + Some(OptType::Root) => serde_json::to_string_pretty(&opt.root), + Some(OptType::Bounding) => serde_json::to_string_pretty(&opt.bounding), + Some(OptType::Timeout) => serde_json::to_string_pretty(&opt.timeout), + Some(OptType::Password) => serde_json::to_string_pretty(&opt.password), + Some(OptType::Authentication) => serde_json::to_string_pretty(&opt.authentication), + Some(OptType::ExecInfo) => serde_json::to_string_pretty(&opt.execinfo), + Some(OptType::UMask) => serde_json::to_string_pretty(&opt.umask), + Some(OptType::Workdir) => serde_json::to_string_pretty(&opt.workdir), + } } fn list_task( @@ -56,87 +75,32 @@ fn list_task( role_type: Option, ) -> Result<(), Box> { if let Some(task_id) = task_id { - if let Some(task) = role.as_ref().borrow().task(&task_id) { - if options { - debug!("task {task:?}"); - let rcopt = OptStack::from_task(&task.clone()) - .to_opt() - .map_err(std::io::Error::other)?; - let opt = rcopt.as_ref().borrow(); - if let Some(opttype) = options_type { - match opttype { - OptType::Env => { - println_ignore_io_error!("{}", serde_json::to_string_pretty(&opt.env)?); - } - OptType::Path => { - println_ignore_io_error!( - "{}", - serde_json::to_string_pretty(&opt.path)? - ); - } - OptType::Root => { - println_ignore_io_error!( - "{}", - serde_json::to_string_pretty(&opt.root)? - ); - } - OptType::Bounding => { - println_ignore_io_error!( - "{}", - serde_json::to_string_pretty(&opt.bounding)? - ); - } - OptType::Timeout => { - println_ignore_io_error!( - "{}", - serde_json::to_string_pretty(&opt.timeout)? - ); - } - OptType::Password => { - println_ignore_io_error!( - "{}", - serde_json::to_string_pretty(&opt.password)? - ); - } - OptType::Authentication => { - println_ignore_io_error!( - "{}", - serde_json::to_string_pretty(&opt.authentication)? - ); - } - OptType::ExecInfo => { - println_ignore_io_error!( - "{}", - serde_json::to_string_pretty(&opt.execinfo)? - ); - } - OptType::UMask => { - println_ignore_io_error!( - "{}", - serde_json::to_string_pretty(&opt.umask)? - ); - } - OptType::Workdir => { - println_ignore_io_error!( - "{}", - serde_json::to_string_pretty(&opt.workdir)? - ); - } - } - } else { - println_ignore_io_error!("{}", serde_json::to_string_pretty(&rcopt)?); - } - } else { - print_task(task, task_type.unwrap_or(TaskType::All))?; - } + let task = role + .as_ref() + .borrow() + .task(&task_id) + .cloned() + .ok_or("Task not found")?; + if options { + debug!("task {task:?}"); + let rcopt = OptStack::from_task(&task) + .to_opt() + .map_err(std::io::Error::other)?; + println_ignore_io_error!( + "{}", + options_to_show(&rcopt.as_ref().borrow(), options_type)? + ); } else { - return Err("Task not found".into()); + print_task(&task, task_type.unwrap_or(TaskType::All))?; } } else if options { let rcopt = OptStack::from_role(&role.clone()) .to_opt() .map_err(std::io::Error::other)?; - println_ignore_io_error!("{}", serde_json::to_string_pretty(&rcopt)?); + println_ignore_io_error!( + "{}", + options_to_show(&rcopt.as_ref().borrow(), options_type)? + ); } else { print_role(role, role_type.unwrap_or(RoleType::All))?; } @@ -290,7 +254,7 @@ pub fn task_add_del( } InputAction::Purge => { let borrow = &role.as_ref().borrow(); - let task = borrow.task(&task_id).expect("Task do not exists"); + let task = borrow.task(&task_id).ok_or("Task not found")?; match task_type { Some(TaskType::Commands) => { task.as_ref().borrow_mut().commands.add.clear(); @@ -313,7 +277,7 @@ pub fn task_add_del( } Ok(true) } - _ => unreachable!("Invalid action"), + _ => Err("Invalid action".into()), } } @@ -351,7 +315,7 @@ pub fn grant_revoke( .retain(|a| !actors.contains(a)); Ok(true) } - _ => unreachable!("Invalid action"), + _ => Err("Invalid action".into()), } } @@ -451,7 +415,7 @@ pub fn cred_caps( .get_or_insert_default() .add = cred_caps; } - _ => unreachable!("Unknown action {:?}", action), + _ => return Err(format!("Unknown action {action:?}").into()), }, SetListType::Black => match action { InputAction::Add => { @@ -477,9 +441,9 @@ pub fn cred_caps( .get_or_insert_default() .sub = cred_caps; } - _ => unreachable!("Unknown action {:?}", action), + _ => return Err(format!("Unknown action {action:?}").into()), }, - _ => unreachable!("Unknown setlist type"), + _ => return Err("Unknown list type".into()), } Ok(true) } @@ -538,7 +502,7 @@ pub fn cmd_whitelist_action( *c != cmd }); } - _ => unreachable!("Unknown action {:?}", action), + _ => return Err(format!("Unknown action {action:?}").into()), }, SetListType::Black => match action { InputAction::Add => { @@ -559,9 +523,9 @@ pub fn cmd_whitelist_action( .sub .retain(|c| c != &cmd); } - _ => unreachable!("Unknown action {:?}", action), + _ => return Err(format!("Unknown action {action:?}").into()), }, - _ => unreachable!("Unknown setlist type"), + _ => return Err("Unknown list type".into()), } Ok(true) } @@ -717,7 +681,7 @@ pub fn path_set( .collect(), ); } - _ => unreachable!("Unknown setlist type"), + _ => return Err("Unknown list type".into()), } Ok(()) })?; @@ -745,7 +709,7 @@ pub fn path_purge( sub.clear(); } } - _ => unreachable!("Unknown setlist type"), + _ => return Err("Unknown list type".into()), } Ok(()) })?; @@ -781,7 +745,7 @@ pub fn workdir_purge( sub.clear(); } } - _ => unreachable!("Unknown setlist type"), + _ => return Err("Unknown list type".into()), } binding.workdir = Some(SWorkdirEither::Struct(workdir_set)); Ok(()) @@ -814,7 +778,7 @@ pub fn env_whitelist_set( env.default_behavior = EnvBehavior::Delete; env.keep = Some(options_env.clone()); } - _ => unreachable!("Unknown setlist type"), + _ => return Err("Unknown list type".into()), } Ok(()) })?; @@ -1003,7 +967,7 @@ pub fn path_setlist2( .collect(), ); } - _ => unreachable!("Unknown action {:?}", action), + _ => return Err(format!("Unknown action {action:?}").into()), }, Some(SetListType::Black) => match action { InputAction::Add => { @@ -1036,9 +1000,9 @@ pub fn path_setlist2( .collect(), ); } - _ => unreachable!("Unknown action {:?}", action), + _ => return Err(format!("Unknown action {action:?}").into()), }, - _ => unreachable!("Unknown setlist type"), + _ => return Err("Unknown list type".into()), } Ok(()) })?; @@ -1111,7 +1075,7 @@ pub fn workdir_setlist( .collect(), ); } - _ => unreachable!("Unknown action {:?}", action), + _ => return Err(format!("Unknown action {action:?}").into()), }, Some(SetListType::Black) => match action { InputAction::Add => { @@ -1144,9 +1108,9 @@ pub fn workdir_setlist( .collect(), ); } - _ => unreachable!("Unknown action {:?}", action), + _ => return Err(format!("Unknown action {action:?}").into()), }, - _ => unreachable!("Unknown setlist type"), + _ => return Err("Unknown list type".into()), } binding.workdir = Some(SWorkdirEither::Struct(workdir_set)); Ok(()) @@ -1253,7 +1217,7 @@ pub fn env_setlist_add( env.keep .replace(options_key_env.as_ref().ok_or("Empty list")?.clone()); } - _ => unreachable!("Unknown action {:?}", action), + _ => return Err(format!("Unknown action {action:?}").into()), }, Some(SetListType::Black) => match action { InputAction::Add => { @@ -1276,7 +1240,7 @@ pub fn env_setlist_add( env.delete .replace(options_key_env.as_ref().ok_or("Empty list")?.clone()); } - _ => unreachable!("Unknown action {:?}", action), + _ => return Err(format!("Unknown action {action:?}").into()), }, Some(SetListType::Check) => match action { InputAction::Add => { @@ -1298,7 +1262,7 @@ pub fn env_setlist_add( InputAction::Purge => { env.check = None; } - _ => unreachable!("Unknown action {:?}", action), + _ => return Err(format!("Unknown action {action:?}").into()), }, Some(SetListType::Set) => match action { InputAction::Add => { @@ -1328,14 +1292,14 @@ pub fn env_setlist_add( env.set .replace(options_env_values.as_ref().ok_or("Empty list")?.clone()); } - _ => unreachable!("Unknown action {:?}", action), + _ => return Err(format!("Unknown action {action:?}").into()), }, None => match action { InputAction::Set => { env.keep .replace(options_key_env.as_ref().ok_or("Empty list")?.clone()); } - _ => unreachable!("Unknown action {:?}", action), + _ => return Err(format!("Unknown action {action:?}").into()), }, } Ok(()) @@ -1358,3 +1322,27 @@ pub fn env_setpolicy( })?; Ok(true) } + +#[cfg(test)] +mod tests { + use rootasrole_core::database::options::{Level, Opt, OptType, SBounding, STimeout}; + + use super::options_to_show; + + #[test] + fn test_options_to_show_only_the_option_asked_for() { + let opt = Opt::builder(Level::Role) + .bounding(SBounding::Strict) + .timeout(STimeout::default()) + .build(); + assert_eq!( + options_to_show(&opt, Some(OptType::Bounding)).unwrap(), + "\"strict\"" + ); + assert_eq!(options_to_show(&opt, Some(OptType::Root)).unwrap(), "null"); + let all: serde_json::Value = + serde_json::from_str(&options_to_show(&opt, None).unwrap()).unwrap(); + assert_eq!(all["bounding"], "strict"); + assert!(all.get("timeout").is_some()); + } +} From 08ce3d7169073fbfa77d2d9010912e1780fd07a6 Mon Sep 17 00:00:00 2001 From: LeChatP Date: Fri, 23 Oct 2026 22:06:03 +0200 Subject: [PATCH 29/88] fix: a timeout without a duration is read, instead of making the whole policy unreadable * fix: STimeout.duration has a deserialize_with but no default, so serde required it although it is an Option: a policy with "timeout": {"type": "tty"} could not be read, neither by chsr nor by dosr, which refused every request; chsr itself wrote such timeouts (options timeout set --type on a level without a timeout) * tests: every field of the timeout may be left out; the chsr tests check that the policy they save parses again Co-Authored-By: Claude --- core/src/database/options.rs | 14 ++++++++++++++ src/chsr/cli/mod.rs | 8 +++++--- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/core/src/database/options.rs b/core/src/database/options.rs index b403f629..57c4c05f 100644 --- a/core/src/database/options.rs +++ b/core/src/database/options.rs @@ -134,7 +134,9 @@ pub struct SPassword { pub struct STimeout { #[serde(default, rename = "type", skip_serializing_if = "Option::is_none")] pub type_field: Option, + // `default`: with `deserialize_with`, serde requires the field, even an `Option` #[serde( + default, serialize_with = "serialize_duration", deserialize_with = "deserialize_duration", skip_serializing_if = "Option::is_none" @@ -1926,6 +1928,18 @@ mod tests { assert_tokens(&umask, &[Token::Str("644")]); } + #[test] + fn test_timeout_fields_are_optional() { + // every field may be left to the upper levels, the duration as the others + let timeout: STimeout = serde_json::from_str(r#"{"type": "tty"}"#).unwrap(); + assert_eq!(timeout.type_field, Some(TimestampType::TTY)); + assert_eq!(timeout.duration, None); + let timeout: STimeout = serde_json::from_str("{}").unwrap(); + assert_eq!(timeout, STimeout::default()); + let opt: Opt = serde_json::from_str(r#"{"timeout": {"max_usage": 2}}"#).unwrap(); + assert_eq!(opt.timeout.unwrap().max_usage, Some(2)); + } + #[test] fn test_password_option() { let password: SPassword = diff --git a/src/chsr/cli/mod.rs b/src/chsr/cli/mod.rs index e1c7c3fa..5b3e257d 100644 --- a/src/chsr/cli/mod.rs +++ b/src/chsr/cli/mod.rs @@ -993,11 +993,13 @@ mod tests { assert!(usage_without_policy(["upgrade"]).is_none()); } - /// The policy as saved in the file of the test `name`. + /// The policy as saved in the file of the test `name`, which must load again (parsed, not + /// opened: the test still holds the lock of the file). fn saved(ctx: &mut TestContext, name: &str) -> Value { ctx.settings.save_all().unwrap(); - serde_json::from_str(&fs::read_to_string(format!("{RAR_CFG_PATH}.{name}")).unwrap()) - .unwrap() + let content = fs::read_to_string(format!("{RAR_CFG_PATH}.{name}")).unwrap(); + serde_json::from_str::(&content).expect("the saved policy loads again"); + serde_json::from_str(&content).unwrap() } fn saved_role<'v>(saved: &'v Value, role: &str) -> &'v Value { From 512c1892022c779961058787485701cec2b791f9 Mon Sep 17 00:00:00 2001 From: LeChatP Date: Fri, 23 Oct 2026 22:11:26 +0200 Subject: [PATCH 30/88] fix: chsr keeps the unknown fields of path, workdir, setuid and storage when it saves * fix: SPathOptions, SWorkdirSet, SSetuidSet, SettingsContent and RemoteStorageSettings had no extra_fields, unlike the other objects of the policy: a field written by a newer version disappeared at the next chsr command; they keep them now, the serializer of SSetuidSet in JSON and CBOR * tests: every unknown field of a policy survives a chsr save; the sets and the storage settings round-trip, CBOR included; cred.capabilities and cred.setgid still refuse unknown fields Co-Authored-By: Claude --- core/src/database/options.rs | 8 ++++ core/src/database/ser.rs | 6 +++ core/src/database/structs.rs | 30 +++++++++++++++ core/src/lib.rs | 9 +++++ src/chsr/cli/mod.rs | 72 ++++++++++++++++++++++++++++++++++++ src/chsr/cli/process/json.rs | 7 +--- 6 files changed, 127 insertions(+), 5 deletions(-) diff --git a/core/src/database/options.rs b/core/src/database/options.rs index 57c4c05f..b3d7f2d0 100644 --- a/core/src/database/options.rs +++ b/core/src/database/options.rs @@ -172,6 +172,10 @@ pub struct SPathOptions { )] #[builder(with = |v : impl IntoIterator| { v.into_iter().map(|s| s.to_string()).collect() })] pub sub: Option>, + /// The fields that this version does not know, kept when the policy is saved again + #[serde(default, flatten, skip_serializing_if = "Map::is_empty")] + #[builder(default)] + pub extra_fields: Map, } #[derive(Serialize, Deserialize, PartialEq, Eq, Debug, Clone, Copy)] @@ -386,6 +390,10 @@ pub struct SWorkdirSet { )] #[builder(with = |v : impl IntoIterator| { v.into_iter().map(|s| s.to_string()).collect() })] pub sub: Option>, + /// The fields that this version does not know, kept when the policy is saved again + #[serde(default, flatten, skip_serializing_if = "Map::is_empty")] + #[builder(default)] + pub extra_fields: Map, } #[derive(Serialize, Deserialize, PartialEq, Eq, Debug, Clone, Copy)] #[serde(rename_all = "lowercase")] diff --git a/core/src/database/ser.rs b/core/src/database/ser.rs index 0c86fd33..053f18c2 100644 --- a/core/src/database/ser.rs +++ b/core/src/database/ser.rs @@ -117,6 +117,9 @@ impl Serialize for SSetuidSet { if !self.sub.is_empty() { map.serialize_entry("del", &self.sub)?; } + for (key, value) in &self.extra_fields { + map.serialize_entry(key, value)?; + } map.end() } else { let mut map = serializer.serialize_map(None)?; @@ -130,6 +133,9 @@ impl Serialize for SSetuidSet { if !self.sub.is_empty() { map.serialize_entry("s", &self.sub)?; } + for (key, value) in &self.extra_fields { + map.serialize_entry(key, value)?; + } map.end() } } diff --git a/core/src/database/structs.rs b/core/src/database/structs.rs index 07464f84..f0799d21 100644 --- a/core/src/database/structs.rs +++ b/core/src/database/structs.rs @@ -264,6 +264,10 @@ pub struct SSetuidSet { )] #[builder(default, with = FromIterator::from_iter)] pub sub: Vec, + /// The fields that this version does not know, kept when the policy is saved again + #[serde(default, flatten, skip_serializing_if = "Map::is_empty")] + #[builder(default)] + pub extra_fields: Map, } #[derive(PartialEq, Eq, Debug, Clone, Copy, Default)] @@ -926,6 +930,8 @@ mod tests { let options = binding.as_ref().borrow(); let env = &options.env.as_ref().unwrap(); assert_eq!(env.extra_fields.get("unknown").unwrap(), "unknown"); + let path = options.path.as_ref().unwrap(); + assert_eq!(path.extra_fields.get("unknown").unwrap(), "unknown"); assert_eq!(options.extra_fields.get("unknown").unwrap(), "unknown"); let timeout = options.timeout.as_ref().unwrap(); assert_eq!(timeout.extra_fields.get("unknown").unwrap(), "unknown"); @@ -966,6 +972,30 @@ mod tests { assert_eq!(commands.extra_fields.get("unknown").unwrap(), "unknown"); } + #[test] + fn test_unknown_fields_of_sets_survive_a_round_trip() { + use crate::{RemoteStorageSettings, SettingsContent, database::options::SWorkdirSet}; + let setuid: SSetuidSet = + serde_json::from_str(r#"{"default": "all", "fallback": "root", "future": 1}"#).unwrap(); + assert_eq!(setuid.extra_fields.get("future"), Some(&Value::from(1))); + assert_eq!(serde_json::to_value(&setuid).unwrap()["future"], 1); + let mut cbor = Vec::new(); + cbor4ii::serde::to_writer(&mut cbor, &setuid).unwrap(); + let back: SSetuidSet = cbor4ii::serde::from_slice(&cbor).unwrap(); + assert_eq!(back, setuid); + let workdir: SWorkdirSet = + serde_json::from_str(r#"{"default": "all", "future": 1}"#).unwrap(); + assert_eq!(serde_json::to_value(&workdir).unwrap()["future"], 1); + let storage: SettingsContent = serde_json::from_str( + r#"{"method": "json", "settings": {"path": "/p", "future": 2}, "future": 1}"#, + ) + .unwrap(); + let storage = serde_json::to_value(&storage).unwrap(); + assert_eq!(storage["future"], 1); + assert_eq!(storage["settings"]["future"], 2); + let _: RemoteStorageSettings = serde_json::from_value(storage["settings"].clone()).unwrap(); + } + #[allow(clippy::too_many_lines)] #[test] fn test_deserialize_alias() { diff --git a/core/src/lib.rs b/core/src/lib.rs index d69b6a23..c36aed85 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -60,6 +60,7 @@ use konst::result; use libc::dev_t; use nix::unistd::{Gid, Group, Pid, Uid, User, getgroups}; use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; /// [`println!`] that ignores write errors: a privileged program must not panic because its /// output is a closed pipe (`dosr -i | true` gets `EPIPE`, since Rust ignores `SIGPIPE`). @@ -184,6 +185,10 @@ pub struct RemoteStorageSettings { #[cfg(feature = "sgbd")] #[serde(skip_serializing_if = "Option::is_none")] pub properties: Option, + /// The fields that this version does not know, kept when the policy is saved again + #[serde(default, flatten, skip_serializing_if = "Map::is_empty")] + #[builder(default)] + pub extra_fields: Map, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] @@ -202,6 +207,10 @@ pub struct SettingsContent { #[cfg(feature = "ldap")] #[serde(skip_serializing_if = "Option::is_none")] pub ldap: Option, + /// The fields that this version does not know, kept when the policy is saved again + #[serde(default, flatten, skip_serializing_if = "Map::is_empty")] + #[builder(default)] + pub extra_fields: Map, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] diff --git a/src/chsr/cli/mod.rs b/src/chsr/cli/mod.rs index 5b3e257d..5310bbf7 100644 --- a/src/chsr/cli/mod.rs +++ b/src/chsr/cli/mod.rs @@ -1198,6 +1198,78 @@ mod tests { } } + /// The paths of the fields named `future` in `value`. + fn future_fields(value: &Value, prefix: &str, out: &mut Vec) { + match value { + Value::Object(map) => { + for (key, value) in map { + let path = format!("{prefix}/{key}"); + if key == "future" { + out.push(path.clone()); + } + future_fields(value, &path, out); + } + } + Value::Array(items) => { + for (i, value) in items.iter().enumerate() { + future_fields(value, &format!("{prefix}[{i}]"), out); + } + } + _ => {} + } + } + + #[test] + fn test_unknown_fields_survive_a_save() { + let path = format!("{RAR_CFG_PATH}.unknown_fields_survive_a_save"); + let _cleanup = defer(|| { + let _ = fs::remove_file(format!("{RAR_CFG_PATH}.unknown_fields_survive_a_save")); + }); + // cred.capabilities and cred.setgid refuse unknown fields, a typo there is an error + let policy = serde_json::json!({ + "version": rootasrole_core::PACKAGE_VERSION.to_string(), + "future": 1, + "storage": {"method": "json", "settings": {"path": path, "future": 1}, "future": 1}, + "options": {"future": 1, + "path": {"default": "delete", "add": ["/usr/bin"], "future": 1}, + "env": {"default": "delete", "keep": ["A"], "future": 1}, + "timeout": {"type": "tty", "future": 1}, + "password": {"tries": 3, "future": 1}, + "workdir": {"default": "all", "fallback": "/", "future": 1}}, + "roles": [{"name": "r", "future": 1, + "actors": [{"type": "user", "id": 0, "future": 1}, + {"type": "group", "groups": ["root"], "future": 1}], + "options": {"future": 1, "path": {"default": "keep-safe", "future": 1}}, + "tasks": [{"name": "t", "future": 1, + "cred": {"future": 1, + "setuid": {"default": "all", "fallback": "root", "future": 1}, + "setgid": {"default": "all", "fallback": ["root"]}, + "capabilities": {"default": "none", "add": ["CAP_NET_RAW"]}}, + "commands": {"default": "none", "add": ["/usr/bin/ls"], "future": 1}, + "options": {"future": 1}}]}] + }); + fs::write(&path, serde_json::to_string_pretty(&policy).unwrap()).unwrap(); + let mut settings = + FileSettings::write_all(path.clone(), path.clone(), StorageMethod::JSON).unwrap(); + assert!( + main(&mut settings, "o bounding strict".split(' ')) + .call() + .unwrap() + ); + settings.save_all().unwrap(); + drop(settings); + let saved: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap(); + let (mut before, mut after) = (Vec::new(), Vec::new()); + future_fields(&policy, "", &mut before); + future_fields(&saved, "", &mut after); + assert_eq!(before.len(), 19); + let lost: Vec<_> = before + .iter() + .filter(|field| !after.contains(field)) + .collect(); + assert!(lost.is_empty(), "lost: {lost:?}"); + } + /// The password option of `level`, as (tries, delay in seconds, unknown fields). fn password(ctx: &TestContext, level: Level) -> Option<(Option, Option, usize)> { ctx.opt(level) diff --git a/src/chsr/cli/process/json.rs b/src/chsr/cli/process/json.rs index 9066920c..0638f0a1 100644 --- a/src/chsr/cli/process/json.rs +++ b/src/chsr/cli/process/json.rs @@ -1151,17 +1151,14 @@ pub fn workdir_setpolicy( || { SWorkdirEither::Struct(SWorkdirSet { default_behavior: options_workdir_policy, - fallback: None, - add: None, - sub: None, + ..Default::default() }) }, |workdir| match workdir { SWorkdirEither::Path(p) => SWorkdirEither::Struct(SWorkdirSet { default_behavior: options_workdir_policy, fallback: Some(p.clone()), - add: None, - sub: None, + ..Default::default() }), SWorkdirEither::Struct(sworkdir_set) => { sworkdir_set.default_behavior = options_workdir_policy; From ead473ddea4bfbac16602f7fc51e2ba1a43efa49 Mon Sep 17 00:00:00 2001 From: LeChatP Date: Fri, 23 Oct 2026 22:14:20 +0200 Subject: [PATCH 31/88] fix: chsr purges a path or workdir list back to inheritance, and keep-only keeps the other env fields * fix: purging a path or workdir list wrote an empty list, which also removes the inherited one (for the sub list, the protections of the upper levels), while purging an env list made it inherited again; every purge now makes the level stop defining the list, an explicit empty list stays a choice of the policy file * fix: env keep-only and delete-only replaced the whole env option of the level, dropping its check and set lists, override and unknown fields; they set the behavior and its list, as their usage says * fix: a command that removes from options that a level does not have leaves no empty options behind, and revoke warns about every actor that is not in the role * tests: purges at a level with and without the option, keep-only then delete-only Co-Authored-By: Claude --- src/chsr/cli/mod.rs | 56 +++++++++++++++++++++++++ src/chsr/cli/process.rs | 5 ++- src/chsr/cli/process/json.rs | 81 ++++++++++++++---------------------- 3 files changed, 92 insertions(+), 50 deletions(-) diff --git a/src/chsr/cli/mod.rs b/src/chsr/cli/mod.rs index 5310bbf7..ef7e8eda 100644 --- a/src/chsr/cli/mod.rs +++ b/src/chsr/cli/mod.rs @@ -1270,6 +1270,62 @@ mod tests { assert!(lost.is_empty(), "lost: {lost:?}"); } + #[test] + fn test_purge_returns_to_inheritance() { + let name = "purge_returns_to_inheritance"; + let (mut ctx, _defer) = TestContext::new(name); + ctx.assert_command_success("o workdir whitelist add /srv"); + ctx.assert_command_success("o workdir blacklist add /tmp"); + // the level no longer defines the list, the upper ones apply again + for command in [ + "o path whitelist purge", + "o path blacklist purge", + "o workdir whitelist purge", + "o env checklist purge", + "o env blacklist purge", + ] { + ctx.assert_command_success(command); + } + let policy = saved(&mut ctx, name); + let options = &policy["options"]; + assert!(options["path"].get("add").is_none(), "{options}"); + assert!(options["path"].get("sub").is_none(), "{options}"); + assert!(options["workdir"].get("add").is_none(), "{options}"); + assert_eq!(options["workdir"]["sub"], serde_json::json!(["/tmp"])); + assert!(options["env"].get("check").is_none(), "{options}"); + assert!(options["env"].get("delete").is_none(), "{options}"); + // on a level without the option, nothing changes + ctx.assert_command_success("r fresh create"); + ctx.assert_command_success("r fresh o path blacklist purge"); + ctx.assert_command_success("r fresh o workdir whitelist purge"); + let policy = saved(&mut ctx, name); + assert!(saved_role(&policy, "fresh").get("options").is_none()); + } + + #[test] + fn test_env_keep_only_keeps_the_other_fields() { + let name = "env_keep_only_keeps_the_other_fields"; + let (mut ctx, _defer) = TestContext::new(name); + ctx.assert_command_success("o env keep-only A,B"); + let env = saved(&mut ctx, name)["options"]["env"].clone(); + assert_eq!( + env, + serde_json::json!({ + "default": "delete", + "keep": ["A", "B"], + "check": ["env3", "env4"], + "delete": ["env5", "env6"], + "set": {"env7": "val7", "env8": "val8"} + }) + ); + ctx.assert_command_success("o env delete-only C"); + let env = saved(&mut ctx, name)["options"]["env"].clone(); + assert_eq!(env["default"], "keep"); + assert_eq!(env["delete"], serde_json::json!(["C"])); + assert_eq!(env["keep"], serde_json::json!(["A", "B"])); + assert_eq!(env["check"], serde_json::json!(["env3", "env4"])); + } + /// The password option of `level`, as (tries, delay in seconds, unknown fields). fn password(ctx: &TestContext, level: Level) -> Option<(Option, Option, usize)> { ctx.opt(level) diff --git a/src/chsr/cli/process.rs b/src/chsr/cli/process.rs index c469ecdc..bccf644b 100644 --- a/src/chsr/cli/process.rs +++ b/src/chsr/cli/process.rs @@ -541,7 +541,10 @@ pub fn perform_on_target_opt( } else { let new_opt = Rc::new(RefCell::new(Opt::builder(level).build())); let result = exec_on_opt(new_opt.clone()); - *options = Some(new_opt); + // a command that removes from options that the level does not have changes nothing + if *new_opt.as_ref().borrow() != Opt::builder(level).build() { + *options = Some(new_opt); + } result } } diff --git a/src/chsr/cli/process/json.rs b/src/chsr/cli/process/json.rs index 0638f0a1..9b3f0859 100644 --- a/src/chsr/cli/process/json.rs +++ b/src/chsr/cli/process/json.rs @@ -9,8 +9,8 @@ use crate::cli::data::{InputAction, PasswordOpt, RoleType, SetListType, TaskType use rootasrole_core::database::{ options::{ - EnvBehavior, EnvKey, Opt, OptStack, OptType, PathBehavior, SEnvOptions, SPassword, - SPathOptions, STimeout, SUMask, SWorkdirEither, SWorkdirSet, WorkdirBehavior, + EnvBehavior, EnvKey, Opt, OptStack, OptType, PathBehavior, SPassword, SPathOptions, + STimeout, SUMask, SWorkdirEither, SWorkdirSet, WorkdirBehavior, }, structs::{ IdTask, RoleGetter, SCapabilities, SCommand, SGroupsEither, SRole, STask, SUserEither, @@ -305,9 +305,11 @@ pub fn grant_revoke( Ok(true) } InputAction::Del => { - //if actor is not in role, warns - if !role.as_ref().borrow().actors.contains(&actors[0]) { - println_ignore_io_error!("Actor {} not in role", actors[0]); + //if an actor is not in role, warns + for actor in &actors { + if !role.as_ref().borrow().actors.contains(actor) { + println_ignore_io_error!("Actor {actor} not in role"); + } } role.as_ref() .borrow_mut() @@ -546,6 +548,8 @@ pub fn cmd_setpolicy( Ok(true) } +/// `chsr o env keep-only|delete-only`: the behavior and its list (keep in delete mode, delete +/// in keep mode); the other env fields of the level (check, set, override, unknown fields) stay. pub fn env_set_policylist( rconfig: &Rc>, role_id: Option<&String>, @@ -555,20 +559,14 @@ pub fn env_set_policylist( ) -> Result> { debug!("chsr o env set keep-only|delete-only {options_env:?}"); perform_on_target_opt(rconfig, role_id, task_id, |opt: Rc>| { - opt.as_ref().borrow_mut().env = Some(SEnvOptions { - default_behavior: options_env_policy, - keep: if options_env_policy.is_delete() { - Some(options_env.clone()) - } else { - None - }, - delete: if options_env_policy.is_keep() { - Some(options_env.clone()) - } else { - None - }, - ..Default::default() - }); + let mut binding = opt.as_ref().borrow_mut(); + let env = binding.env.get_or_insert_default(); + env.default_behavior = options_env_policy; + if options_env_policy.is_delete() { + env.keep = Some(options_env.clone()); + } else if options_env_policy.is_keep() { + env.delete = Some(options_env.clone()); + } Ok(()) })?; Ok(true) @@ -688,6 +686,9 @@ pub fn path_set( Ok(true) } +/// `chsr o path (whitelist|blacklist) purge`: the level no longer defines the list, which is +/// inherited again, like the env lists. An explicit empty list would also remove the inherited +/// one: for the `sub` list, protections of the upper levels. pub fn path_purge( rconfig: &Rc>, role_id: Option<&String>, @@ -697,18 +698,12 @@ pub fn path_purge( debug!("chsr o path purge"); perform_on_target_opt(rconfig, role_id, task_id, |opt: Rc>| { let mut binding = opt.as_ref().borrow_mut(); - let path = binding.path.get_or_insert_default(); + let Some(path) = binding.path.as_mut() else { + return Ok(()); + }; match setlist_type { - Some(SetListType::White) => { - if let Some(add) = &mut path.add { - add.clear(); - } - } - Some(SetListType::Black) => { - if let Some(sub) = &mut path.sub { - sub.clear(); - } - } + Some(SetListType::White) => path.add = None, + Some(SetListType::Black) => path.sub = None, _ => return Err("Unknown list type".into()), } Ok(()) @@ -716,6 +711,8 @@ pub fn path_purge( Ok(true) } +/// `chsr o workdir (whitelist|blacklist) purge`: the level no longer defines the list, see +/// [`path_purge`]. pub fn workdir_purge( rconfig: &Rc>, role_id: Option<&String>, @@ -725,29 +722,15 @@ pub fn workdir_purge( debug!("chsr o workdir purge"); perform_on_target_opt(rconfig, role_id, task_id, |opt: Rc>| { let mut binding = opt.as_ref().borrow_mut(); - let workdir_current = binding.workdir.take(); - let mut workdir_set = match workdir_current { - Some(SWorkdirEither::Struct(workdir_set)) => workdir_set, - Some(SWorkdirEither::Path(path)) => SWorkdirSet { - fallback: Some(path), - ..Default::default() - }, - None => SWorkdirSet::default(), + let Some(SWorkdirEither::Struct(workdir_set)) = binding.workdir.as_mut() else { + // no list at this level: a bare path or nothing + return Ok(()); }; match setlist_type { - Some(SetListType::White) => { - if let Some(add) = &mut workdir_set.add { - add.clear(); - } - } - Some(SetListType::Black) => { - if let Some(sub) = &mut workdir_set.sub { - sub.clear(); - } - } + Some(SetListType::White) => workdir_set.add = None, + Some(SetListType::Black) => workdir_set.sub = None, _ => return Err("Unknown list type".into()), } - binding.workdir = Some(SWorkdirEither::Struct(workdir_set)); Ok(()) })?; Ok(true) From 50cb99a3d646b3f120d3b6213eea832a47b08a66 Mon Sep 17 00:00:00 2001 From: LeChatP Date: Fri, 23 Oct 2026 22:16:41 +0200 Subject: [PATCH 32/88] fix: chsr saves the groups and the user given to cred set, and refuses a umask that is not octal * fix: cred set --setgid root,adm wrote "setgid": [], the groups were looked for among the direct children of the parse and they are nested in a combination * fix: cred set -u root wrote "setuid": "", the user was taken after the 9 characters of "--setuid ", whatever form was used * fix: options umask 999 set the built-in default umask, a value that is not octal fell back to the default; a umask is octal, 000 to 777, or an error * tests: the simple edits of every level read back from the saved file, the forms of --setuid and --setgid, the umask Co-Authored-By: Claude --- src/chsr/cli/mod.rs | 139 +++++++++++++++++++++++++++++++++++++++---- src/chsr/cli/pair.rs | 59 ++++++++++++------ 2 files changed, 167 insertions(+), 31 deletions(-) diff --git a/src/chsr/cli/mod.rs b/src/chsr/cli/mod.rs index ef7e8eda..35c90a9b 100644 --- a/src/chsr/cli/mod.rs +++ b/src/chsr/cli/mod.rs @@ -995,7 +995,7 @@ mod tests { /// The policy as saved in the file of the test `name`, which must load again (parsed, not /// opened: the test still holds the lock of the file). - fn saved(ctx: &mut TestContext, name: &str) -> Value { + fn saved_policy(ctx: &mut TestContext, name: &str) -> Value { ctx.settings.save_all().unwrap(); let content = fs::read_to_string(format!("{RAR_CFG_PATH}.{name}")).unwrap(); serde_json::from_str::(&content).expect("the saved policy loads again"); @@ -1030,7 +1030,7 @@ mod tests { ctx.assert_command_success("r fresh o root user"); ctx.assert_command_success("r fresh t t1 add"); ctx.assert_command_success("r fresh t t1 o path whitelist add /opt/bin"); - let saved = saved(&mut ctx, name); + let saved = saved_policy(&mut ctx, name); // the other options stay inherited, not written with the built-in defaults assert_eq!(saved["options"], serde_json::json!({"bounding": "strict"})); assert_eq!( @@ -1055,7 +1055,7 @@ mod tests { // removing from a list that the level does not define changes nothing ctx.assert_command_success("r fresh o env blacklist del LD_PRELOAD"); ctx.assert_command_success("r fresh t t1 o path whitelist del /usr/bin"); - let saved = saved(&mut ctx, name); + let saved = saved_policy(&mut ctx, name); assert_eq!( saved_task(&saved, "fresh", "t1")["options"], serde_json::json!({"env": {"keep": ["MYVAR"], "check": ["TZ"]}}) @@ -1074,17 +1074,21 @@ mod tests { ctx.assert_command_success("o timeout set --type tty"); ctx.assert_command_success("o timeout set -d 5:00"); assert_eq!( - saved(&mut ctx, name)["options"]["timeout"], + saved_policy(&mut ctx, name)["options"]["timeout"], serde_json::json!({"type": "tty", "duration": "00:05:00", "max_usage": 1}) ); // unset removes the fields named, then the option once nothing is left ctx.assert_command_success("o timeout unset --max-usage"); assert_eq!( - saved(&mut ctx, name)["options"]["timeout"], + saved_policy(&mut ctx, name)["options"]["timeout"], serde_json::json!({"type": "tty", "duration": "00:05:00"}) ); ctx.assert_command_success("o timeout unset --type --duration"); - assert!(saved(&mut ctx, name)["options"].get("timeout").is_none()); + assert!( + saved_policy(&mut ctx, name)["options"] + .get("timeout") + .is_none() + ); // a field that this version does not know is kept ctx.opt(Level::Role) .as_ref() @@ -1108,7 +1112,7 @@ mod tests { assert!(ctx.run_command(command).is_err(), "{command}"); } ctx.assert_command_no_change("r complete t t_complete o show timeout"); - let saved = saved(&mut ctx, name); + let saved = saved_policy(&mut ctx, name); assert!(saved["options"].get("timeout").is_none()); assert_eq!( saved_role(&saved, "complete")["options"]["timeout"], @@ -1136,7 +1140,7 @@ mod tests { ] { assert!(ctx.run_command(command).is_err(), "{command}"); } - let saved = saved(&mut ctx, name); + let saved = saved_policy(&mut ctx, name); assert_eq!( saved["options"]["env"]["keep"], serde_json::json!(["env1", "env2", "A", "LC_*", "*_PROXY"]) @@ -1286,7 +1290,7 @@ mod tests { ] { ctx.assert_command_success(command); } - let policy = saved(&mut ctx, name); + let policy = saved_policy(&mut ctx, name); let options = &policy["options"]; assert!(options["path"].get("add").is_none(), "{options}"); assert!(options["path"].get("sub").is_none(), "{options}"); @@ -1298,7 +1302,7 @@ mod tests { ctx.assert_command_success("r fresh create"); ctx.assert_command_success("r fresh o path blacklist purge"); ctx.assert_command_success("r fresh o workdir whitelist purge"); - let policy = saved(&mut ctx, name); + let policy = saved_policy(&mut ctx, name); assert!(saved_role(&policy, "fresh").get("options").is_none()); } @@ -1307,7 +1311,7 @@ mod tests { let name = "env_keep_only_keeps_the_other_fields"; let (mut ctx, _defer) = TestContext::new(name); ctx.assert_command_success("o env keep-only A,B"); - let env = saved(&mut ctx, name)["options"]["env"].clone(); + let env = saved_policy(&mut ctx, name)["options"]["env"].clone(); assert_eq!( env, serde_json::json!({ @@ -1319,13 +1323,124 @@ mod tests { }) ); ctx.assert_command_success("o env delete-only C"); - let env = saved(&mut ctx, name)["options"]["env"].clone(); + let env = saved_policy(&mut ctx, name)["options"]["env"].clone(); assert_eq!(env["default"], "keep"); assert_eq!(env["delete"], serde_json::json!(["C"])); assert_eq!(env["keep"], serde_json::json!(["A", "B"])); assert_eq!(env["check"], serde_json::json!(["env3", "env4"])); } + #[test] + fn test_simple_edits_are_saved() { + let name = "simple_edits_are_saved"; + let (mut ctx, _defer) = TestContext::new(name); + for command in [ + "o root user", + "o bounding strict", + "o auth skip", + "o info hide", + "o umask 077", + "r complete o root user", + "r complete o umask 027", + "r complete t t_complete o workdir set /srv", + "r complete t t_complete o auth perform", + "r complete grant -u alice -g staff&wheel", + "r complete t t_complete cred set --setuid root --setgid root,adm", + "r complete t t_complete cred caps whitelist add cap_net_raw", + "r complete t t_complete cmd whitelist add /usr/bin/id -u", + "r complete t t_complete cmd setpolicy deny-all", + "r complete t t2 add", + ] { + ctx.assert_command_success(command); + } + let saved = saved_policy(&mut ctx, name); + let global = &saved["options"]; + assert_eq!(global["root"], "user"); + assert_eq!(global["bounding"], "strict"); + assert_eq!(global["authentication"], "skip"); + assert_eq!(global["execinfo"], "hide"); + assert_eq!(global["umask"], "077"); + let role = saved_role(&saved, "complete"); + assert_eq!(role["options"]["root"], "user"); + assert_eq!(role["options"]["umask"], "027"); + let actors = role["actors"].as_array().unwrap(); + assert!(actors.contains(&serde_json::json!({"type": "user", "id": "alice"}))); + assert!( + actors.contains(&serde_json::json!({"type": "group", "groups": ["staff", "wheel"]})) + ); + let task = saved_task(&saved, "complete", "t_complete"); + assert_eq!(task["options"]["workdir"], "/srv"); + assert_eq!(task["options"]["authentication"], "perform"); + assert_eq!(task["cred"]["setuid"], "root"); + assert_eq!(task["cred"]["setgid"], serde_json::json!(["root", "adm"])); + assert!( + task["cred"]["capabilities"]["add"] + .as_array() + .unwrap() + .contains(&Value::from("NET_RAW")) + ); + assert_eq!(task["commands"]["default"], "none"); + assert!( + task["commands"]["add"] + .as_array() + .unwrap() + .contains(&Value::from("/usr/bin/id -u")) + ); + assert_eq!(saved_task(&saved, "complete", "t2")["name"], "t2"); + // deletions + for command in ["o root del", "o umask del", "r complete t t2 del"] { + ctx.assert_command_success(command); + } + let saved = saved_policy(&mut ctx, name); + assert!(saved["options"].get("root").is_none()); + assert!(saved["options"].get("umask").is_none()); + assert!( + saved_role(&saved, "complete")["tasks"] + .as_array() + .unwrap() + .iter() + .all(|task| task["name"] != "t2") + ); + } + + #[test] + fn test_cred_setuid_and_setgid_forms() { + let name = "cred_setuid_and_setgid_forms"; + let (mut ctx, _defer) = TestContext::new(name); + for (command, setuid, setgid) in [ + ( + "r complete t t_complete cred set -u root -g adm", + "root", + serde_json::json!("adm"), + ), + ( + "r complete t t_complete cred set --setuid=nobody --setgid=root&adm", + "nobody", + serde_json::json!(["root", "adm"]), + ), + ( + "r complete t t_complete cred set --setuid daemon --setgid root,adm,staff", + "daemon", + serde_json::json!(["root", "adm", "staff"]), + ), + ] { + ctx.assert_command_success(command); + let saved = saved_policy(&mut ctx, name); + let cred = &saved_task(&saved, "complete", "t_complete")["cred"]; + assert_eq!(cred["setuid"], setuid, "{command}"); + assert_eq!(cred["setgid"], setgid, "{command}"); + } + } + + #[test] + fn test_umask_is_octal() { + let (mut ctx, _defer) = TestContext::new("umask_is_octal"); + for command in ["o umask 999", "o umask 1000", "o umask 7777"] { + assert!(ctx.run_command(command).is_err(), "{command}"); + } + assert!(ctx.opt(Level::Global).as_ref().borrow().umask.is_none()); + } + /// The password option of `level`, as (tries, delay in seconds, unknown fields). fn password(ctx: &TestContext, level: Level) -> Option<(Option, Option, usize)> { ctx.opt(level) diff --git a/src/chsr/cli/pair.rs b/src/chsr/cli/pair.rs index a93e03ce..64973996 100644 --- a/src/chsr/cli/pair.rs +++ b/src/chsr/cli/pair.rs @@ -10,7 +10,9 @@ use crate::cli::data::{Convertion, RoleType, TaskType}; use rootasrole_core::{ database::{ actor::{SActor, SGroupType}, - options::{EnvBehavior, EnvKey, OptType, PathBehavior, TimestampType, WorkdirBehavior}, + options::{ + EnvBehavior, EnvKey, OptType, PathBehavior, SUMask, TimestampType, WorkdirBehavior, + }, structs::{IdTask, SetBehavior}, }, util::{StorageMethod, is_valid_env_name}, @@ -36,6 +38,20 @@ pub fn recurse_pair(pair: Pair, inputs: &mut Inputs) -> Result<(), Box) -> Vec { + pair.clone() + .into_inner() + .flat_map(|inner| { + if inner.as_rule() == Rule::actor_name { + vec![inner.as_str().to_string()] + } else { + actor_names(&inner) + } + }) + .collect() +} + /// A duration written `ss`, `mm:ss` or `hh:mm:ss`: a field that does not fit, or a duration /// that does not, is an error, never 0 nor a panic. fn parse_time(time: &str) -> Result> { @@ -336,26 +352,21 @@ fn match_pair(pair: &Pair, inputs: &mut Inputs) -> Result<(), Box { - inputs.cred_setuid = Some( - pair.as_str() - .chars() - .skip(9) - .collect::() - .as_str() - .into(), - ); + let user = actor_names(pair) + .into_iter() + .next() + .ok_or("--setuid needs a user")?; + inputs.cred_setuid = Some(user.as_str().into()); } Rule::cred_g => { - let mut vec: Vec = Vec::new(); - for pair in pair.clone().into_inner() { - if pair.as_rule() == Rule::actor_name { - vec.push(pair.as_str().into()); - } + let groups: Vec = actor_names(pair) + .iter() + .map(|name| name.as_str().into()) + .collect(); + if groups.is_empty() { + return Err("--setgid needs a group".into()); } - if vec.is_empty() { - warn!("No group specified"); - } - inputs.cred_setgid = Some(vec.into()); + inputs.cred_setgid = Some(groups.into()); } // === options === Rule::options_operations => { @@ -462,7 +473,17 @@ fn match_pair(pair: &Pair, inputs: &mut Inputs) -> Result<(), Box { inputs.action = InputAction::Set; // If del it will be overwritten by the parse loop - inputs.options_umask = Some(pair.as_str().parse().unwrap_or_default()); + let value = pair.as_str(); + inputs.options_umask = Some(if value.bytes().all(|b| b.is_ascii_digit()) { + u16::from_str_radix(value, 8) + .ok() + .filter(|umask| *umask <= 0o777) + .map(SUMask) + .ok_or_else(|| format!("The umask is octal, 000 to 777: {value}"))? + } else { + // del: the value is not used + SUMask::default() + }); } Rule::all => { if inputs.role_id.is_some() && inputs.task_id.is_none() { From 00db348ca0d495b0496f5ce2a3b4d4672e39e2b3 Mon Sep 17 00:00:00 2001 From: LeChatP Date: Fri, 23 Oct 2026 22:21:30 +0200 Subject: [PATCH 33/88] fix: chsr edits one file of a policy directory, and writes only the files that changed * fix: with a policy directory (the layout of the Debian package and of cargo xtask install), chsr only read and wrote the policy embedded in the root file, which dosr ignores: chsr list printed {} and chsr role r add went nowhere useful * feat: a command edits the file named with chsr file (its path, or its name in the directory), else default.json, the only policy file, or the policy of the root file when there is no other; with several files and no default.json, chsr asks for one instead of guessing * feat: chsr list shows every file of the policy by its path, chsr file list lists the files * fix: saving rewrote every file, the unchanged ones in the typed form of the actors; a file is now written only when its data changed since it was loaded or saved * tests: a directory with default.json and another file written in short form, by name and by path, a file that is not one of the policy, and a directory without default.json Co-Authored-By: Claude --- core/src/file.rs | 44 +++++++++++++--- core/src/util.rs | 3 ++ src/chsr/cli/mod.rs | 113 ++++++++++++++++++++++++++++++++++++++++ src/chsr/cli/process.rs | 77 +++++++++++++++++++++++++-- 4 files changed, 227 insertions(+), 10 deletions(-) diff --git a/core/src/file.rs b/core/src/file.rs index 082fafd4..b1161ecb 100644 --- a/core/src/file.rs +++ b/core/src/file.rs @@ -55,9 +55,11 @@ pub struct LockedSettingsFile pub data: T, /// The migration applied to the document when it was loaded, until it is saved. migration: Option, + /// The data as loaded, or as last saved: a file whose data is the same is not written + /// again. `None` when the data did not come from the file. + saved: Option, } -/// This opens, deserialize and locks a settings file, and keeps the file descriptor open to keep the lock /// How a policy file is opened to be written. A new file is only readable and writable by its /// owner (the umask can only remove more), like the files of the installers: the policy must not /// be readable, nor writable, by other users (see `check_others_permissions`). An existing file @@ -68,6 +70,7 @@ fn write_options() -> OpenOptions { options } +/// This opens, deserialize and locks a settings file, and keeps the file descriptor open to keep the lock /// it allows to save the settings file later impl LockedSettingsFile { /// # Errors @@ -111,9 +114,11 @@ impl LockedSettingsFile { nix::fcntl::FlockArg::LockExclusive, )?; + let data = data_loader(&path, &file)?; Ok(Self { path: path.as_ref().to_path_buf(), - data: data_loader(&path, &file)?, + saved: serde_json::to_value(&data).ok(), + data, fd: file, migration: None, }) @@ -129,6 +134,13 @@ impl LockedSettingsFile { load_data() } + /// Whether the data differs from what the file holds: changed since it was loaded or saved, + /// or not loaded from the file. + #[must_use] + pub fn changed(&self) -> bool { + self.saved.is_none() || self.saved != serde_json::to_value(&self.data).ok() + } + /// # Errors /// Returns an error if the file cannot be written /// due to a lock, permission error or writing error @@ -156,6 +168,7 @@ impl LockedSettingsFile { debug!("Wrote config file"); } self.migration = None; + self.saved = serde_json::to_value(&self.data).ok(); Ok(()) } @@ -190,10 +203,13 @@ pub struct FileSettings { ///# Errors /// Returns an error if any of the files cannot be opened or locked #[builder(with = |path: PathBuf,config : RootSettings| -> Result<_, Box> { - Ok(LockedSettingsFile::open_write(path, |_, _| { + let mut root = LockedSettingsFile::open_write(path, |_, _| { let versioned: Versioning = Versioning::new(config.clone()); Ok(versioned) - })?) + })?; + // made from these settings, not read from the file: it is written by the next save + root.saved = None; + Ok(root) })] root: LockedRootSettings, /// Policy files of a directory that could not be loaded, with the reason. @@ -456,9 +472,11 @@ impl FileSettings { }) }) { // Open the new file with the new path - let new_root = LockedSettingsFile::open_write(path, |_, _| { + let mut new_root = LockedSettingsFile::open_write(path, |_, _| { Ok(Versioning::new(self.root.data.data.clone())) })?; + // the file at the new path does not hold these settings yet + new_root.saved = None; debug!( "Moved root settings to new path: {}", new_root.path.display() @@ -466,7 +484,11 @@ impl FileSettings { drop(std::mem::replace(&mut self.root, new_root)); } - let mut has_errors = if let Err(e) = self.root.save(RAR_CFG_TYPE, immutable) { + // only the files that changed: the others are not rewritten (in the typed form of the + // actors, without their immutable flag for a while...) + let mut has_errors = if !self.root.changed() { + false + } else if let Err(e) = self.root.save(RAR_CFG_TYPE, immutable) { error!("Failed to save root settings: {e}"); true } else { @@ -474,7 +496,7 @@ impl FileSettings { false }; - for config in &mut self.map { + for config in self.map.iter_mut().filter(|config| config.changed()) { if let Err(e) = config.save(self.root.data.data.storage.method, immutable) { error!( "Failed to save settings for {}: {}", @@ -566,6 +588,14 @@ impl FileSettings { pub fn get_policies(&self) -> Vec<&Rc>> { self.map.iter().map(|config| &config.data.data).collect() } + + /// The policy files apart from the root file (those of a policy directory, or the one file + /// that the root file names), with their path. + pub fn policy_files(&self) -> impl Iterator>)> { + self.map + .iter() + .map(|config| (config.path.as_path(), &config.data.data)) + } } #[cfg(test)] diff --git a/core/src/util.rs b/core/src/util.rs index e5f93f63..28e22dab 100644 --- a/core/src/util.rs +++ b/core/src/util.rs @@ -59,6 +59,9 @@ pub(super) const RAR_CFG_IMMUTABLE: bool = false; #[cfg(not(debug_assertions))] pub(super) const RAR_CFG_IMMUTABLE: bool = eq_str(env!("RAR_CFG_IMMUTABLE"), "true"); +/// The policy file that chsr edits in a policy directory, when no file is named: the file that +/// the installers write there. +pub const DEFAULT_POLICY_FILE: &str = "default.json"; pub const RAR_CFG_TYPE: StorageMethod = StorageMethod::const_parse(env!("RAR_CFG_TYPE")); pub const ENV_PATH_BEHAVIOR: PathBehavior = PathBehavior::const_parse(env!("RAR_PATH_DEFAULT")); diff --git a/src/chsr/cli/mod.rs b/src/chsr/cli/mod.rs index 35c90a9b..1e44bd79 100644 --- a/src/chsr/cli/mod.rs +++ b/src/chsr/cli/mod.rs @@ -1441,6 +1441,119 @@ mod tests { assert!(ctx.opt(Level::Global).as_ref().borrow().umask.is_none()); } + /// A root file pointing to a policy directory holding `files`, as the Debian package and + /// `cargo xtask install` lay them out. Returns the root file and the directory. + fn policy_directory( + name: &str, + files: &[(&str, Value)], + ) -> (String, String, impl Drop + use<>) { + let root = format!("{RAR_CFG_PATH}.{name}"); + let dir = format!("{RAR_CFG_PATH}.{name}.d"); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + let version = rootasrole_core::PACKAGE_VERSION.to_string(); + fs::write( + &root, + serde_json::to_string_pretty(&serde_json::json!({ + "version": version, + "storage": {"method": "json", "settings": {"path": dir, "immutable": false}} + })) + .unwrap(), + ) + .unwrap(); + for (file, policy) in files { + let mut policy = policy.clone(); + policy["version"] = Value::from(version.clone()); + fs::write( + format!("{dir}/{file}"), + serde_json::to_string_pretty(&policy).unwrap(), + ) + .unwrap(); + } + let (root_cleanup, dir_cleanup) = (root.clone(), dir.clone()); + let cleanup = defer(move || { + let _ = fs::remove_file(&root_cleanup); + let _ = fs::remove_dir_all(&dir_cleanup); + }); + (root, dir, cleanup) + } + + /// Run `command` with chsr on the policy of `root`, and save it like chsr does. + fn chsr_on(root: &str, command: &str) -> Result> { + let mut settings = + FileSettings::write_all(root.to_string(), root.to_string(), StorageMethod::JSON)?; + let changed = main(&mut settings, command.split(' ')).call()?; + if changed { + settings.save_all()?; + } + Ok(changed) + } + + fn role_names(path: &str) -> Vec { + let policy: Value = serde_json::from_str(&fs::read_to_string(path).unwrap()).unwrap(); + policy["roles"] + .as_array() + .unwrap() + .iter() + .map(|role| role["name"].as_str().unwrap().to_string()) + .collect() + } + + #[test] + fn test_policy_directory_targets_one_file() { + let (root, dir, _cleanup) = policy_directory( + "policy_directory_targets_one_file", + &[ + ("default.json", serde_json::json!({"roles": []})), + ( + "extra.json", + serde_json::json!({"roles": [{"name": "x", "actors": [{"user": "root"}]}]}), + ), + ], + ); + let (default, extra) = (format!("{dir}/default.json"), format!("{dir}/extra.json")); + let root_before = fs::read_to_string(&root).unwrap(); + let extra_before = fs::read_to_string(&extra).unwrap(); + // without a file named, the role goes to default.json, the files that did not change + // are not written again (extra.json keeps its short form) + assert!(chsr_on(&root, "r newrole create").unwrap()); + assert_eq!(role_names(&default), ["newrole"]); + assert_eq!(fs::read_to_string(&root).unwrap(), root_before); + assert_eq!(fs::read_to_string(&extra).unwrap(), extra_before); + // a file named by its name in the directory, or by its path + let default_before = fs::read_to_string(&default).unwrap(); + assert!(chsr_on(&root, "file extra.json r other create").unwrap()); + assert!(chsr_on(&root, &format!("file {extra} r another create")).unwrap()); + assert_eq!(role_names(&extra), ["x", "other", "another"]); + assert_eq!(fs::read_to_string(&default).unwrap(), default_before); + assert!(chsr_on(&root, "file extra.json r x grant -u daemon").unwrap()); + // a file that is not one of the policy + assert!(chsr_on(&root, "file nope.json r y create").is_err()); + assert!(chsr_on(&root, "file /etc/passwd r y create").is_err()); + // listings change nothing + assert!(!chsr_on(&root, "list").unwrap()); + assert!(!chsr_on(&root, "file list").unwrap()); + assert!(!chsr_on(&root, "file extra.json list").unwrap()); + assert_eq!(fs::read_to_string(&default).unwrap(), default_before); + } + + #[test] + fn test_policy_directory_without_default_file() { + let (root, dir, _cleanup) = policy_directory( + "policy_directory_without_default_file", + &[ + ("a.json", serde_json::json!({"roles": []})), + ("b.json", serde_json::json!({"roles": []})), + ], + ); + // which file a role belongs to is not guessed + let error = chsr_on(&root, "r y create").unwrap_err().to_string(); + assert!(error.contains("chsr file"), "{error}"); + assert!(chsr_on(&root, "file b.json r y create").unwrap()); + assert_eq!(role_names(&format!("{dir}/b.json")), ["y"]); + assert!(role_names(&format!("{dir}/a.json")).is_empty()); + } + /// The password option of `level`, as (tries, delay in seconds, unknown fields). fn password(ctx: &TestContext, level: Level) -> Option<(Option, Option, usize)> { ctx.opt(level) diff --git a/src/chsr/cli/process.rs b/src/chsr/cli/process.rs index bccf644b..f340c862 100644 --- a/src/chsr/cli/process.rs +++ b/src/chsr/cli/process.rs @@ -2,7 +2,7 @@ mod convert; mod json; mod upgrade; -use std::{cell::RefCell, error::Error, rc::Rc}; +use std::{cell::RefCell, error::Error, ffi::OsStr, path::Path, rc::Rc}; use json::{ cmd_setpolicy, cmd_whitelist_action, cred_caps, cred_set, cred_setpolicy, cred_unset, @@ -17,9 +17,11 @@ use log::debug; use rootasrole_core::{ database::{ options::{Level, Opt, OptType}, - structs::{IdTask, RoleGetter}, + structs::{IdTask, RoleGetter, SPolicy}, }, file::FileSettings, + println_ignore_io_error, + util::DEFAULT_POLICY_FILE, }; use crate::cli::process::json::{ @@ -45,7 +47,31 @@ pub fn process_input(storage: &mut FileSettings, inputs: Inputs) -> Result Result Err("Unknown Input".into()), } } +/// The policy that a command edits: the file named with `chsr file ` (its path, or its +/// name in the policy directory), else `default.json` of the policy directory, the only policy +/// file, or the policy of the root file when there is no other file. +fn target_policy( + storage: &FileSettings, + policy_path: Option<&str>, +) -> Result>, Box> { + let files: Vec<_> = storage.policy_files().collect(); + let names = || { + files + .iter() + .map(|(path, _)| path.display().to_string()) + .collect::>() + .join(", ") + }; + if let Some(name) = policy_path { + let wanted = Path::new(name); + if let Some(policy) = storage.get(wanted) { + return Ok(policy.clone()); + } + return files + .iter() + .find(|(path, _)| { + wanted.components().count() == 1 && path.file_name() == Some(wanted.as_os_str()) + }) + .map(|(_, policy)| Rc::clone(policy)) + .ok_or_else(|| format!("{name} is not a file of the policy: {}", names()).into()); + } + match files.as_slice() { + [] => Ok(storage.get_root().config.clone()), + [(_, policy)] => Ok(Rc::clone(policy)), + _ => files + .iter() + .find(|(path, _)| path.file_name() == Some(OsStr::new(DEFAULT_POLICY_FILE))) + .map(|(_, policy)| Rc::clone(policy)) + .ok_or_else(|| { + format!( + "The policy has several files and no {DEFAULT_POLICY_FILE}, choose one with chsr file : {}", + names() + ) + .into() + }), + } +} + pub fn perform_on_target_opt( rconfig: &Rc>, role_id: Option<&String>, From eeed93c4aa583effda3a2f1142197c1096f3900b Mon Sep 17 00:00:00 2001 From: LeChatP Date: Fri, 23 Oct 2026 22:22:33 +0200 Subject: [PATCH 34/88] tests: end-to-end check of chsr on the policy directory of the installation * tests: e2e.sh creates and deletes a role with chsr: it lands in default.json, the root file and the other policy file keep their content byte for byte, and chsr list shows the files of the directory Co-Authored-By: Claude --- tests/vagrant/e2e.sh | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/vagrant/e2e.sh b/tests/vagrant/e2e.sh index 2b5892d9..36135bd1 100755 --- a/tests/vagrant/e2e.sh +++ b/tests/vagrant/e2e.sh @@ -309,6 +309,23 @@ ssd_role_disabled_by_a_failed_lookup() { fi } +# with the policy directory of the installation, chsr edits default.json, and leaves the root +# file and the other policy files as they are +chsr_edits_the_default_file() { + local root_before e2e_before found + [ -f "$POLICY_DIR/default.json" ] || return 77 + root_before=$(sha256sum <"$ROOT_FILE") + e2e_before=$(sha256sum <"$E2E_POLICY") + chsr role rar-e2e-chsr create || return 1 + found=$(grep -c '"rar-e2e-chsr"' "$POLICY_DIR/default.json") + chsr role rar-e2e-chsr delete || return 1 + [ "$found" -ge 1 ] && + ! grep -q '"rar-e2e-chsr"' "$POLICY_DIR/default.json" && + [ "$(sha256sum <"$ROOT_FILE")" = "$root_before" ] && + [ "$(sha256sum <"$E2E_POLICY")" = "$e2e_before" ] && + chsr list | grep -q "$POLICY_DIR/default.json" +} + trap cleanup EXIT setup check "the policy, its directory included, is immutable" policy_is_immutable @@ -322,4 +339,5 @@ check "the bounding set is checked before running the command" bounding_set_is_c check "the variables of pam_env go through the env filter of the task" pam_env_is_filtered check "a wrong password is asked again, up to the tries of the policy" password_is_asked_again check "a failed lookup of an actor disables the roles with ssd" ssd_role_disabled_by_a_failed_lookup +check "chsr edits default.json of the policy directory" chsr_edits_the_default_file exit "$FAILED" From 466fa4d803fd232643951389ec20ae527c285ed3 Mon Sep 17 00:00:00 2001 From: LeChatP Date: Fri, 23 Oct 2026 22:53:06 +0200 Subject: [PATCH 35/88] fix: chsr reads the setuid set like dosr, refusing the fields it does not know * fix: dosr refuses unknown fields in the three credential sets (setuid, setgid, capabilities), so that a typo there cannot leave a privilege granted; rootasrole-core refused them for setgid and capabilities, but b4b5a37 made it keep them for setuid, so chsr could save a policy that dosr refuses; the setuid set refuses them again, every other object keeps them * tests: the three credential sets refuse a typo, and a setuid set round-trips in CBOR Co-Authored-By: Claude --- core/src/database/ser.rs | 6 ------ core/src/database/structs.rs | 38 ++++++++++++++++++++++++++---------- src/chsr/cli/mod.rs | 6 +++--- 3 files changed, 31 insertions(+), 19 deletions(-) diff --git a/core/src/database/ser.rs b/core/src/database/ser.rs index 053f18c2..0c86fd33 100644 --- a/core/src/database/ser.rs +++ b/core/src/database/ser.rs @@ -117,9 +117,6 @@ impl Serialize for SSetuidSet { if !self.sub.is_empty() { map.serialize_entry("del", &self.sub)?; } - for (key, value) in &self.extra_fields { - map.serialize_entry(key, value)?; - } map.end() } else { let mut map = serializer.serialize_map(None)?; @@ -133,9 +130,6 @@ impl Serialize for SSetuidSet { if !self.sub.is_empty() { map.serialize_entry("s", &self.sub)?; } - for (key, value) in &self.extra_fields { - map.serialize_entry(key, value)?; - } map.end() } } diff --git a/core/src/database/structs.rs b/core/src/database/structs.rs index f0799d21..dab6e5f4 100644 --- a/core/src/database/structs.rs +++ b/core/src/database/structs.rs @@ -239,8 +239,10 @@ impl From for SUserEither { } } +/// Like the other credentials, a set of users refuses the fields it does not know, as dosr +/// does: a typo there must not leave a privilege granted. #[derive(Deserialize, Debug, Clone, Builder, PartialEq, Eq)] - +#[serde(deny_unknown_fields)] pub struct SSetuidSet { #[serde( alias = "d", @@ -264,10 +266,6 @@ pub struct SSetuidSet { )] #[builder(default, with = FromIterator::from_iter)] pub sub: Vec, - /// The fields that this version does not know, kept when the policy is saved again - #[serde(default, flatten, skip_serializing_if = "Map::is_empty")] - #[builder(default)] - pub extra_fields: Map, } #[derive(PartialEq, Eq, Debug, Clone, Copy, Default)] @@ -973,16 +971,36 @@ mod tests { } #[test] - fn test_unknown_fields_of_sets_survive_a_round_trip() { - use crate::{RemoteStorageSettings, SettingsContent, database::options::SWorkdirSet}; + fn test_credentials_refuse_unknown_fields() { + // a typo in what grants privileges is an error, as in dosr + assert!( + serde_json::from_str::( + r#"{"default": "all", "fallback": "root", "fallbak": "x"}"# + ) + .is_err() + ); + assert!( + serde_json::from_str::( + r#"{"default": "all", "fallback": ["root"], "sbu": ["adm"]}"# + ) + .is_err() + ); + assert!( + serde_json::from_str::(r#"{"default": "all", "sbu": ["SYS_ADMIN"]}"#) + .is_err() + ); let setuid: SSetuidSet = - serde_json::from_str(r#"{"default": "all", "fallback": "root", "future": 1}"#).unwrap(); - assert_eq!(setuid.extra_fields.get("future"), Some(&Value::from(1))); - assert_eq!(serde_json::to_value(&setuid).unwrap()["future"], 1); + serde_json::from_str(r#"{"default": "all", "fallback": "root", "add": ["daemon"]}"#) + .unwrap(); let mut cbor = Vec::new(); cbor4ii::serde::to_writer(&mut cbor, &setuid).unwrap(); let back: SSetuidSet = cbor4ii::serde::from_slice(&cbor).unwrap(); assert_eq!(back, setuid); + } + + #[test] + fn test_unknown_fields_of_sets_survive_a_round_trip() { + use crate::{RemoteStorageSettings, SettingsContent, database::options::SWorkdirSet}; let workdir: SWorkdirSet = serde_json::from_str(r#"{"default": "all", "future": 1}"#).unwrap(); assert_eq!(serde_json::to_value(&workdir).unwrap()["future"], 1); diff --git a/src/chsr/cli/mod.rs b/src/chsr/cli/mod.rs index 1e44bd79..bdda2f4f 100644 --- a/src/chsr/cli/mod.rs +++ b/src/chsr/cli/mod.rs @@ -1229,7 +1229,7 @@ mod tests { let _cleanup = defer(|| { let _ = fs::remove_file(format!("{RAR_CFG_PATH}.unknown_fields_survive_a_save")); }); - // cred.capabilities and cred.setgid refuse unknown fields, a typo there is an error + // the credential sets (setuid, setgid, capabilities) refuse unknown fields, as dosr does let policy = serde_json::json!({ "version": rootasrole_core::PACKAGE_VERSION.to_string(), "future": 1, @@ -1246,7 +1246,7 @@ mod tests { "options": {"future": 1, "path": {"default": "keep-safe", "future": 1}}, "tasks": [{"name": "t", "future": 1, "cred": {"future": 1, - "setuid": {"default": "all", "fallback": "root", "future": 1}, + "setuid": {"default": "all", "fallback": "root"}, "setgid": {"default": "all", "fallback": ["root"]}, "capabilities": {"default": "none", "add": ["CAP_NET_RAW"]}}, "commands": {"default": "none", "add": ["/usr/bin/ls"], "future": 1}, @@ -1266,7 +1266,7 @@ mod tests { let (mut before, mut after) = (Vec::new(), Vec::new()); future_fields(&policy, "", &mut before); future_fields(&saved, "", &mut after); - assert_eq!(before.len(), 19); + assert_eq!(before.len(), 18); let lost: Vec<_> = before .iter() .filter(|field| !after.contains(field)) From 69ceb2b7b823ace7a82d9ca39895e5712ac151fc Mon Sep 17 00:00:00 2001 From: LeChatP Date: Fri, 23 Oct 2026 22:53:06 +0200 Subject: [PATCH 36/88] fix: cargo xtask install deploys the PAM service of dosr instead of an empty file * fix: the PAM service files were read at run time from ../../resources, a path relative to the source file, not to the working directory: from the project root it named /home/resources, and the privileged installer, root without effective capabilities, could not search a home directory of mode 0700 (Permission denied on Debian) * fix: /etc/pam.d/dosr was created before its content was read, so the failure left an empty PAM service that the next installation kept; the services are now built into xtask with include_str!, an empty file is replaced, and uninstall compares with the built-in services Co-Authored-By: Claude --- xtask/src/configure.rs | 38 ++++++++++++++++++++------------ xtask/src/installer/uninstall.rs | 19 +++++++++------- 2 files changed, 35 insertions(+), 22 deletions(-) diff --git a/xtask/src/configure.rs b/xtask/src/configure.rs index b682b0db..017c3f47 100644 --- a/xtask/src/configure.rs +++ b/xtask/src/configure.rs @@ -340,26 +340,36 @@ fn resolve_policy_path(base_path: &Path) -> PathBuf { base_path.to_path_buf() } } -pub fn pam_config(os: &OsTarget) -> std::io::Result { +/// The PAM services of each distribution, built into xtask: reading them at run time depended on +/// the working directory, which the privileged installer may not even be able to search. +pub const PAM_CONFIGS: &[&str] = &[ + include_str!("../../resources/debian/deb_sr_pam.conf"), + include_str!("../../resources/rh/rh_sr_pam.conf"), + include_str!("../../resources/opensuse/opensuse.conf"), + include_str!("../../resources/arch/arch_sr_pam.conf"), +]; + +#[must_use] +pub const fn pam_config(os: &OsTarget) -> &'static str { match os { - OsTarget::Debian | OsTarget::Ubuntu => { - std::fs::read_to_string("../../resources/debian/deb_sr_pam.conf") - } - OsTarget::RedHat | OsTarget::Fedora => { - std::fs::read_to_string("../../resources/rh/rh_sr_pam.conf") - } - OsTarget::OpenSUSE => std::fs::read_to_string("../../resources/opensuse/opensuse.conf"), - OsTarget::ArchLinux => std::fs::read_to_string("../../resources/arch/arch_sr_pam.conf"), + OsTarget::Debian | OsTarget::Ubuntu => PAM_CONFIGS[0], + OsTarget::RedHat | OsTarget::Fedora => PAM_CONFIGS[1], + OsTarget::OpenSUSE => PAM_CONFIGS[2], + OsTarget::ArchLinux => PAM_CONFIGS[3], } } +/// Deploy the PAM service of dosr when it is missing, or empty: an empty file is what a failed +/// installation left, it is not a configuration of the administrator. fn deploy_pam_config(os: &OsTarget) -> io::Result { - if fs::metadata(Path::new("/etc/pam.d").join(PAM_CONFIG_SERVICE)).is_err() { - info!("Deploying PAM configuration file"); - let mut pam_conf = File::create(Path::new("/etc/pam.d").join(PAM_CONFIG_SERVICE))?; - pam_conf.write_all(pam_config(os)?.as_bytes())?; - pam_conf.sync_all()?; + let path = Path::new("/etc/pam.d").join(PAM_CONFIG_SERVICE); + if fs::metadata(&path).is_ok_and(|meta| meta.len() > 0) { + return Ok(0); } + info!("Deploying PAM configuration file"); + let mut pam_conf = File::create(&path)?; + pam_conf.write_all(pam_config(os).as_bytes())?; + pam_conf.sync_all()?; Ok(0) } diff --git a/xtask/src/installer/uninstall.rs b/xtask/src/installer/uninstall.rs index 00077629..8c82be61 100644 --- a/xtask/src/installer/uninstall.rs +++ b/xtask/src/installer/uninstall.rs @@ -2,24 +2,27 @@ use anyhow::Context; use log::warn; use std::fs; -use crate::util::{ - ImmutableLock, PAM_CONFIG_SERVICE, RAR_CFG_DATA_PATH, RAR_CFG_PATH, files_are_equal, - toggle_lock_config, +use crate::{ + configure::PAM_CONFIGS, + util::{ + ImmutableLock, PAM_CONFIG_SERVICE, RAR_CFG_DATA_PATH, RAR_CFG_PATH, toggle_lock_config, + }, }; use super::{CHSR_DEST, SR_DEST, UninstallOptions}; +// a Result like every command of xtask, for the dispatch in main +#[allow(clippy::unnecessary_wraps)] pub fn uninstall(opts: &UninstallOptions) -> Result<(), anyhow::Error> { let mut errors = vec![]; if opts.kind.is_all() || opts.kind.is_sr() { let pam_config_path = format!("/etc/pam.d/{PAM_CONFIG_SERVICE}"); errors.push(fs::remove_file(SR_DEST).context(SR_DEST)); errors.push(fs::remove_file(CHSR_DEST).context(CHSR_DEST)); - if opts.clean_config - || files_are_equal("resources/debian/deb_sr_pam.conf", &pam_config_path)? - || files_are_equal("resources/rh/rh_sr_pam.conf", &pam_config_path)? - || files_are_equal("resources/arch/arch_sr_pam.conf", &pam_config_path)? - { + // the PAM service is removed if it is one that the installer deployed + let deployed = fs::read_to_string(&pam_config_path) + .is_ok_and(|content| PAM_CONFIGS.contains(&content.as_str())); + if opts.clean_config || deployed { errors.push(fs::remove_file(&pam_config_path).context(pam_config_path)); } if opts.clean_config { From 63a351dfdb6a40da30298beee48ce6004f84732a Mon Sep 17 00:00:00 2001 From: LeChatP Date: Fri, 23 Oct 2026 22:55:03 +0200 Subject: [PATCH 37/88] tests: the Vagrant provisioning builds and installs RootAsRole again * tests: provision.sh ran its scripts with sudo -i, which escapes the newlines of the command: the login shell joined the lines as continuations and only sourced the cargo environment, so cargo xtask install never ran and the VM kept whatever dosr it had; the scripts run with sudo -H and set -e now * tests: the chsr --help integration test skips when chsr is not built (cargo integration-test only enables finder), CARGO_BIN_EXE_chsr is set anyway Co-Authored-By: Claude --- tests/integration_tests.rs | 5 ++++- tests/vagrant/provision.sh | 8 ++++++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/tests/integration_tests.rs b/tests/integration_tests.rs index 415dd2eb..b1a573ad 100644 --- a/tests/integration_tests.rs +++ b/tests/integration_tests.rs @@ -65,7 +65,10 @@ mod tests { /// chsr prints its usage without reading the policy, so without any privilege. #[test] fn test_chsr_help_without_the_policy() { - let Some(chsr) = option_env!("CARGO_BIN_EXE_chsr") else { + // set even when chsr is not built (without the feature editor) + let Some(chsr) = + option_env!("CARGO_BIN_EXE_chsr").filter(|chsr| std::path::Path::new(chsr).exists()) + else { eprintln!("Skipping: chsr is not built (feature editor)"); return; }; diff --git a/tests/vagrant/provision.sh b/tests/vagrant/provision.sh index 4929e5a0..a4ef1b2c 100755 --- a/tests/vagrant/provision.sh +++ b/tests/vagrant/provision.sh @@ -18,13 +18,17 @@ case "$ID" in ;; esac +# The scripts run as vagrant with -H, not -i: sudo -i escapes the newlines of a command, which +# the login shell then joins as line continuations, so only the first line ran. # the build needs a recent Rust, the one of the distribution may be too old -sudo -u vagrant -i bash -c ' +sudo -u vagrant -H bash -c ' + set -euo pipefail command -v "$HOME/.cargo/bin/cargo" >/dev/null || curl --proto "=https" --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal ' # the other dependencies come from xtask (-i), sudo is the privilege escalation it uses (-p) -sudo -u vagrant -i bash -c ' +sudo -u vagrant -H bash -c ' + set -euo pipefail . "$HOME/.cargo/env" cd "$HOME/RootAsRole" cargo xtask install -bip sudo From 9576a0ac6287ca96a2b699bf38fe07cd92f2afa0 Mon Sep 17 00:00:00 2001 From: LeChatP Date: Fri, 23 Oct 2026 22:59:09 +0200 Subject: [PATCH 38/88] fix: cargo xtask install installs a copy of root, not the build artifact itself * fix: dosr and chsr were installed by renaming target//dosr to /usr/bin/dosr: the installed binary was the file that the user had built, still linked as target//deps/dosr-; until the installer changed its owner the user owned the file that was about to get every capability, and afterwards any chown -R of the build tree (vagrant rsync does one) gave it back to the user and removed its capabilities * fix: each binary is copied into a new file of root beside its destination, then renamed over it, so that a running dosr is never overwritten * tests: e2e.sh checks that the installed binaries belong to root, with one link, and that dosr has its capabilities; the check of a failed group lookup edits the current /etc/group, not the copy taken before the group was added Co-Authored-By: Claude --- tests/vagrant/e2e.sh | 11 +++++- xtask/src/installer/install.rs | 67 ++++++++++++++++------------------ 2 files changed, 42 insertions(+), 36 deletions(-) diff --git a/tests/vagrant/e2e.sh b/tests/vagrant/e2e.sh index 36135bd1..f4e5ee02 100755 --- a/tests/vagrant/e2e.sh +++ b/tests/vagrant/e2e.sh @@ -160,6 +160,14 @@ cleanup() { userdel -r "$TEST_USER" 2>/dev/null } +# the installed binaries are files of root, not shared with the build tree (one link), and dosr +# has its file capabilities +installed_binaries_belong_to_root() { + [ "$(stat -c '%u %h' /usr/bin/dosr)" = "0 1" ] && + [ "$(stat -c '%u %h' /usr/bin/chsr)" = "0 1" ] && + getcap /usr/bin/dosr | grep -q '=p' +} + # the installation sets the immutable flag on the policy, its directory included policy_is_immutable() { immutable_policy || return 77 @@ -296,7 +304,7 @@ ssd_role_disabled_by_a_failed_lookup() { seq -f 'rar%07g' 1 120000 | paste -sd, >"$members" awk -F: -v OFS=: -v group="$BIG_GROUP" -v list="$members" \ 'BEGIN { getline all < list } $1 == group { $4 = all } { print }' \ - "$GROUP_BACKUP" >"$members.group" + /etc/group >"$members.group" cat "$members.group" >/etc/group since=$(date +%s) after=$(as_user dosr -r e2e-guarded -t uid /usr/bin/id -u 2>/dev/null) @@ -328,6 +336,7 @@ chsr_edits_the_default_file() { trap cleanup EXIT setup +check "the installed binaries belong to root, dosr has its capabilities" installed_binaries_belong_to_root check "the policy, its directory included, is immutable" policy_is_immutable check "a policy file without the immutable flag is refused" planted_policy_is_refused check "dosr ends like a command killed by a signal" signal_is_forwarded diff --git a/xtask/src/installer/install.rs b/xtask/src/installer/install.rs index a8ea1a9b..925b5a94 100644 --- a/xtask/src/installer/install.rs +++ b/xtask/src/installer/install.rs @@ -1,6 +1,7 @@ use std::env::{self, current_exe, set_current_dir}; use std::fs::{self, File}; use std::io; +use std::os::unix::fs::OpenOptionsExt; use std::path::{Path, PathBuf}; use std::str::FromStr; @@ -31,6 +32,35 @@ fn shell_quote(arg: &str) -> String { } } +/// Install `source` at `dest` as a new file of root: a copy, never the build artifact itself. +/// The artifact belongs to the user who built it, and cargo keeps it linked in `target/`: once +/// renamed to `dest`, the installed binary was the same file as one of the build tree, so a +/// `chown -R` of the tree gave it to the user and removed its capabilities, and the user owned +/// it until the installer changed its owner. The copy is made beside `dest` then renamed over +/// it, so that a running binary is never overwritten. +fn install_file(source: &Path, dest: &Path) -> Result<(), anyhow::Error> { + let name = dest + .file_name() + .and_then(|name| name.to_str()) + .context("the destination has no file name")?; + let tmp = dest.with_file_name(format!(".{name}.{}.new", std::process::id())); + // a file of a failed installation, in a directory that only root can write + let _ = fs::remove_file(&tmp); + let mut input = File::open(source)?; + let mut output = fs::OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o700) + .open(&tmp)?; + let copied = io::copy(&mut input, &mut output).and_then(|_| output.sync_all()); + drop(output); + if let Err(e) = copied.and_then(|()| fs::rename(&tmp, dest)) { + let _ = fs::remove_file(&tmp); + return Err(e.into()); + } + Ok(()) +} + fn copy_executables(profile: Profile) -> Result<(), anyhow::Error> { let chsr_dest = Path::new(RAR_BIN_PATH).join(CHSR_DEST); let sr_dest = Path::new(RAR_BIN_PATH).join(SR_DEST); @@ -58,41 +88,8 @@ fn copy_executables(profile: Profile) -> Result<(), anyhow::Error> { return Err(anyhow!("sr or chsr does not exist in the target directory. \nYou may need first to do `sudo cargo clean`.\n{BOLD}{RED}Please build the project first using `cargo xtask build`{RST}")); } - // We can't use fs::copy directly because it will overwrite the destination file - // and it is possible that the destination file is currently under execution. - debug!("Copying sr to sr.tmp"); - fs::copy(sr, format!("{s_sr}.tmp"))?; - debug!("Copying chsr to chsr.tmp"); - fs::copy(chsr, format!("{s_chsr}.tmp"))?; - debug!("Moving sr to /usr/bin/dosr"); - match fs::rename(sr, &sr_dest) { - Ok(()) => {} - Err(e) if e.raw_os_error() == Some(18) => { - // EXDEV (errno 18): Invalid cross-device link - // Fall back to copy + remove for cross-filesystem operations - debug!("Cross-device link detected, using copy+remove instead"); - fs::copy(sr, &sr_dest)?; - fs::remove_file(sr)?; - } - Err(e) => return Err(e.into()), - } - debug!("Moving chsr to /usr/bin/chsr"); - match fs::rename(chsr, &chsr_dest) { - Ok(()) => {} - Err(e) if e.raw_os_error() == Some(18) => { - // EXDEV (errno 18): Invalid cross-device link - // Fall back to copy + remove for cross-filesystem operations - debug!("Cross-device link detected, using copy+remove instead"); - fs::copy(chsr, &chsr_dest)?; - fs::remove_file(chsr)?; - } - Err(e) => return Err(e.into()), - } - debug!("Renaming sr.tmp to sr"); - fs::rename(format!("{s_sr}.tmp"), sr)?; - debug!("Renaming chsr.tmp to chsr"); - fs::rename(format!("{s_chsr}.tmp"), chsr)?; - + install_file(sr, &sr_dest).context("Failed to install dosr")?; + install_file(chsr, &chsr_dest).context("Failed to install chsr")?; Ok(()) } From 79334f4816a81c9487437ccdc77f788970d6bc17 Mon Sep 17 00:00:00 2001 From: LeChatP Date: Fri, 23 Oct 2026 23:05:24 +0200 Subject: [PATCH 39/88] fix: dosr -r no longer lifts the separation of duties * fix: with -r, the roles of another name were skipped while the policy was read, actors included: the SSD plugin did not see the roles that the user holds, so dosr -r dev granted dev to a member of auditor although dev excludes auditor; without -r the request was refused * fix: every role is read now, and -r applies when the role is chosen; the hierarchy plugin also gets the parents again, so dosr -r senior gives the tasks of its parents * tests: -r chooses among the roles that allow the command, refuses a role that the user does not hold, keeps the separation of duties and the tasks of the parents Co-Authored-By: Claude --- src/dosr/finder/de/roles.rs | 18 +++----- src/dosr/finder/mod.rs | 89 +++++++++++++++++++++++++++++++++++++ 2 files changed, 94 insertions(+), 13 deletions(-) diff --git a/src/dosr/finder/de/roles.rs b/src/dosr/finder/de/roles.rs index 5198a10e..299915e8 100644 --- a/src/dosr/finder/de/roles.rs +++ b/src/dosr/finder/de/roles.rs @@ -11,7 +11,7 @@ use rootasrole_core::{ }; use serde::{ Deserialize, - de::{DeserializeSeed, IgnoredAny, Visitor}, + de::{DeserializeSeed, Visitor}, }; use serde_json::Value; @@ -127,18 +127,10 @@ impl<'de: 'a, 'a> DeserializeSeed<'de> for RoleFinderDeserializer<'a> { } Field::Name => { debug!("RoleFinderVisitor: name"); - let role_name = map.next_value()?; - if self - .cli - .opt_filter - .as_ref() - .and_then(|x| x.role.as_ref()) - .is_some_and(|r| r != &role_name) - { - while map.next_entry::()?.is_some() {} - return Ok(None); - } - role = Some(role_name); + // every role is read, -r included: the plugins need the others, + // the SSD plugin to know which roles the user holds, the hierarchy + // plugin for the tasks of the parents; -r applies when choosing + role = Some(map.next_value()?); } Field::Actors => { debug!("RoleFinderVisitor: actors"); diff --git a/src/dosr/finder/mod.rs b/src/dosr/finder/mod.rs index 8b203c1b..920d5e2c 100644 --- a/src/dosr/finder/mod.rs +++ b/src/dosr/finder/mod.rs @@ -278,7 +278,15 @@ impl BestExecSettings { let mut result = Self::default(); let mut matching = false; let mut opt_stack = BorrowedOptStack::new(data.options.as_ref()); + let role_filter = cli + .opt_filter + .as_ref() + .and_then(|filter| filter.role.as_deref()); for role in data.roles() { + // the role asked with -r, the others were read for the plugins + if role_filter.is_some_and(|name| name != role.role().role.as_ref()) { + continue; + } // only the roles that the user holds: their actors match and no plugin excludes // them (separation of duties), the hierarchy plugin relies on it if !result.actors_settings(&role)? { @@ -1502,6 +1510,87 @@ mod tests { )); } + /// The role and task chosen for `cmd` with `dosr -r role`. + fn choose_role(roles: &Value, cmd: &str, role: &str) -> SrResult<(String, Option)> { + api::register_plugins(); + let policy = serde_json::json!({ "roles": roles }).to_string(); + let cli = Cli::builder() + .cmd_path(cmd.to_string()) + .opt_filter(FilterMatcher::builder().role(role.to_string()).build()) + .build(); + let cred = dummy_cred(); + let data = super::de::ConfigFinderDeserializer { + cli: &cli, + cred: &cred, + } + .deserialize(&mut serde_json::Deserializer::from_str(&policy)) + .expect("valid policy"); + BestExecSettings::retrieve_settings(&cli, &cred, &data, vec![("A", "B")], &["/usr/bin"]) + .map(|settings| (settings.role, settings.task)) + } + + #[test] + fn test_role_filter_chooses_among_the_roles() { + let none = serde_json::json!({}); + let roles = serde_json::json!([ + role_json("first", true, Some("/usr/bin/ls"), &none), + role_json("second", true, Some("/usr/bin/ls"), &none), + role_json("other", false, Some("/usr/bin/ls"), &none), + ]); + assert_eq!( + choose_role(&roles, "/usr/bin/ls", "second").unwrap().0, + "second" + ); + assert_eq!( + choose_role(&roles, "/usr/bin/ls", "first").unwrap().0, + "first" + ); + // a role that the user does not hold, or that does not exist + for role in ["other", "nope"] { + assert!(matches!( + choose_role(&roles, "/usr/bin/ls", role), + Err(SrError::PermissionDenied) + )); + } + } + + #[cfg(feature = "ssd")] + #[test] + fn test_role_filter_does_not_lift_the_separation_of_duties() { + let none = serde_json::json!({}); + let roles = serde_json::json!([ + role_json( + "dev", + true, + Some("/usr/bin/ls"), + &serde_json::json!({"ssd": ["auditor"]}) + ), + role_json("auditor", true, None, &none), + ]); + // dosr -r dev read only the role dev: auditor, which the user holds, was not seen + assert!(matches!( + choose_role(&roles, "/usr/bin/ls", "dev"), + Err(SrError::PermissionDenied) + )); + } + + #[cfg(feature = "hierarchy")] + #[test] + fn test_role_filter_keeps_the_tasks_of_the_parents() { + let none = serde_json::json!({}); + let roles = serde_json::json!([ + role_json( + "senior", + true, + None, + &serde_json::json!({"parents": ["junior"]}) + ), + role_json("junior", false, Some("/usr/bin/ls"), &none), + ]); + let (role, task) = choose_role(&roles, "/usr/bin/ls", "senior").unwrap(); + assert_eq!(task.as_deref(), Some("junior_task"), "{role}"); + } + #[test] fn test_default_policy_runs_as_the_requested_user() { api::register_plugins(); From bb231cc27d2d2154d907dd492440e04434292c0c Mon Sep 17 00:00:00 2001 From: LeChatP Date: Fri, 23 Oct 2026 23:05:25 +0200 Subject: [PATCH 40/88] feat: chsr edits the separation of duties, the parents, the purpose, env override and hashed commands * feat: chsr role R ssd|parents add|del|set R2,R3 and ssd|parents purge, a list of role names in the role; a role cannot name itself, a role that does not exist yet is told * feat: chsr role R task T purpose set and purpose unset * feat: chsr options env override allow|deny|unset, whether dosr -E may change the env behavior, at every level * feat: cmd whitelist|blacklist add --sha224|--sha256|--sha384|--sha512 [--read-only] [--immutable] , for the hashchecker plugin, with a warning that it is deprecated; the hash is checked, a malformed one is an error, never part of the command; del removes a command in either form, and add refuses a command line that is already listed in either form * tests: each command read back from the saved file; ssd del and purpose unset do not delete the role or the task, which the same keywords do Co-Authored-By: Claude --- src/chsr/cli/cli.pest | 34 +++++- src/chsr/cli/data.rs | 42 ++++++++ src/chsr/cli/mod.rs | 116 ++++++++++++++++++++ src/chsr/cli/pair.rs | 75 ++++++++++++- src/chsr/cli/process.rs | 51 ++++++++- src/chsr/cli/process/json.rs | 198 ++++++++++++++++++++++++++++------- src/chsr/cli/usage.rs | 62 ++++++++++- 7 files changed, 524 insertions(+), 54 deletions(-) diff --git a/src/chsr/cli/cli.pest b/src/chsr/cli/cli.pest index c2d6b2c7..f1f22875 100644 --- a/src/chsr/cli/cli.pest +++ b/src/chsr/cli/cli.pest @@ -72,12 +72,20 @@ policy_path = @{ name } role = { ("role" | "r") ~ roles_operations } roles_operations = { purge | role_operations } -role_operations = { role_id ~ ( add | del | role_show_purge | role_grant_revoke | tasks_operations | options_operations) } +role_operations = { role_id ~ ( add | del | role_show_purge | role_grant_revoke | role_links | tasks_operations | options_operations) } role_id = @{ name } role_grant_revoke = { (grant | revoke) ~ user_or_groups } role_show_purge = { (list|purge) ~ role_type_arg? } role_type_arg = @{ "actors" | "tasks" | all } +// chsr r r1 ssd add r2,r3 the roles that a member of r1 must not hold (ssd plugin) +// chsr r r1 parents set r2 the roles whose tasks r1 gets (hierarchy plugin) +// chsr r r1 ssd purge +role_links = { role_link_kind ~ ((add | del | set) ~ linked_roles | purge) } +role_link_kind = { "ssd" | "parents" } +linked_roles = { linked_role ~ ("," ~ linked_role)* } +linked_role = @{ (!("," | WHITESPACE) ~ ANY)+ } + user_or_groups = { (user | group)+ } user = ${ ("--user" ~ assignment | "-u" ~ WHITESPACE+) ~ actor_name } @@ -95,7 +103,13 @@ actor_name = @{ (CASED_LETTER | "_") ~ ((CASED_LETTER | ASCII_DIGIT // chsr r r1 t t1 del tasks_operations = { task_spec ~ (task_operations | options_operations) } -task_operations = { task_show_purge | add | del | command_operations | credentials_operations } +task_operations = { task_show_purge | add | del | command_operations | credentials_operations | purpose_operations } + +// chsr r r1 t t1 purpose set Back up the databases +// chsr r r1 t t1 purpose unset +purpose_operations = _{ purpose_keyword ~ (set ~ purpose_text | del) } +purpose_keyword = { "purpose" } +purpose_text = ${ ANY+ } task_show_purge = { (list | purge) ~ task_type_arg? } task_type_arg = { all | cmd_keyword | cred_keyword } @@ -116,8 +130,15 @@ command_operations = _{ cmd_keyword ~ (cmd_setpolicy | cmd_checklisting) } cmd_setpolicy = { setpolicy ~ cmd_policy } cmd_policy = { "allow-all" | "deny-all" } -cmd_checklisting = { (whitelist | blacklist) ~ ((add | del) ~ cmd | purge) } -cmd = ${ ANY+ } +cmd_checklisting = { (whitelist | blacklist) ~ ((add | del) ~ cmd_checks? ~ cmd | purge) } +// chsr r r1 t t1 cmd whitelist add --sha256 --read-only /usr/bin/ls (hashchecker plugin) +cmd_checks = { (cmd_hash | cmd_check_flag)+ } +cmd_hash = ${ cmd_hash_type ~ ("=" | WHITESPACE+) ~ cmd_hash_value } +cmd_hash_type = { "--sha224" | "--sha256" | "--sha384" | "--sha512" } +cmd_hash_value = @{ (!WHITESPACE ~ ANY)+ } +cmd_check_flag = { "--read-only" | "--immutable" } +// a command never starts with a check: a malformed check is an error, not part of the command +cmd = ${ !("--sha" | "--read-only" | "--immutable") ~ ANY+ } // ======================== // credentials @@ -192,7 +213,10 @@ opt_workdir_listing = { (whitelist | blacklist) ~ (((add | del | set) ~ path) workdir_policy = { "all" | "none" | "inherit" } opt_env = { "env" ~ (opt_env_args | help) } -opt_env_args = _{ opt_env_setpolicy | opt_env_keep | opt_env_delete | opt_env_set | opt_env_listing | opt_env_setlisting } +opt_env_args = _{ opt_env_override | opt_env_setpolicy | opt_env_keep | opt_env_delete | opt_env_set | opt_env_listing | opt_env_setlisting } +// chsr o env override allow|deny|unset whether dosr -E may change the env behavior +opt_env_override = { "override" ~ (env_override_value | del) } +env_override_value = { "allow" | "deny" } opt_env_setpolicy = { setpolicy ~ env_policy } env_policy = { "delete-all" | "keep-all" | "inherit" } opt_env_listing = { (whitelist | blacklist | checklist) ~ (((add | del | set) ~ env_key_list) | purge) } diff --git a/src/chsr/cli/data.rs b/src/chsr/cli/data.rs index 8caecd61..40c78801 100644 --- a/src/chsr/cli/data.rs +++ b/src/chsr/cli/data.rs @@ -58,6 +58,42 @@ pub enum SetListType { Set, } +/// The links of a role to other roles, kept in the role for the plugins. +#[derive(Debug, PartialEq, Eq, Clone, Copy)] +pub enum RoleLink { + /// The roles that a member of the role must not hold (ssd plugin) + Ssd, + /// The roles whose tasks the role gets (hierarchy plugin) + Parents, +} + +impl RoleLink { + #[must_use] + pub const fn key(self) -> &'static str { + match self { + Self::Ssd => "ssd", + Self::Parents => "parents", + } + } +} + +/// Whether `dosr -E` may change the env behavior, for `chsr o env override`. +#[derive(Debug, PartialEq, Eq, Clone, Copy)] +pub enum EnvOverride { + Allow, + Deny, + Unset, +} + +/// The checks of the hashchecker plugin on the file of a command (deprecated, removed in 5.0). +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct CommandChecks { + /// The hash type (`sha256`...) and the hash in hexadecimal + pub hash: Option<(String, String)>, + pub read_only: bool, + pub immutable: bool, +} + #[derive(Debug, PartialEq, Eq, Clone, Copy)] #[repr(usize)] pub enum PasswordOpt { @@ -85,6 +121,12 @@ pub struct Inputs { pub timeout_type: Option, pub timeout_duration: Option, pub timeout_max_usage: Option, + pub role_link: Option, + pub role_links: Option>, + pub purpose: bool, + pub task_purpose: Option, + pub cmd_checks: CommandChecks, + pub options_env_override: Option, pub password_arg: Option<[bool; 2]>, pub password_tries: Option, pub password_delay: Option, diff --git a/src/chsr/cli/mod.rs b/src/chsr/cli/mod.rs index bdda2f4f..fd3e338b 100644 --- a/src/chsr/cli/mod.rs +++ b/src/chsr/cli/mod.rs @@ -1554,6 +1554,122 @@ mod tests { assert!(role_names(&format!("{dir}/a.json")).is_empty()); } + #[test] + fn test_role_links() { + let name = "role_links"; + let (mut ctx, _defer) = TestContext::new(name); + ctx.assert_command_success("r auditor create"); + ctx.assert_command_success("r junior create"); + ctx.assert_command_success("r complete ssd add auditor"); + ctx.assert_command_success("r complete ssd add auditor,other"); + ctx.assert_command_success("r complete parents set junior"); + let saved = saved_policy(&mut ctx, name); + let role = saved_role(&saved, "complete"); + assert_eq!(role["ssd"], serde_json::json!(["auditor", "other"])); + assert_eq!(role["parents"], serde_json::json!(["junior"])); + // del and purge change the links, never the role (del also deletes a role) + ctx.assert_command_success("r complete ssd del other"); + ctx.assert_command_success("r complete parents purge"); + let saved = saved_policy(&mut ctx, name); + let role = saved_role(&saved, "complete"); + assert_eq!(role["ssd"], serde_json::json!(["auditor"])); + assert!(role.get("parents").is_none()); + // a role cannot exclude itself or be its own parent + assert!(ctx.run_command("r complete ssd add complete").is_err()); + assert!( + ctx.run_command("r complete parents add junior,complete") + .is_err() + ); + assert!(ctx.run_command("r nope ssd add auditor").is_err()); + } + + #[test] + fn test_task_purpose() { + let name = "task_purpose"; + let (mut ctx, _defer) = TestContext::new(name); + ctx.assert_command_success("r complete t t_complete purpose set Back up the \"databases\""); + let saved = saved_policy(&mut ctx, name); + assert_eq!( + saved_task(&saved, "complete", "t_complete")["purpose"], + "Back up the databases" + ); + // unset removes the purpose, never the task (unset also deletes a task) + ctx.assert_command_success("r complete t t_complete purpose unset"); + let saved = saved_policy(&mut ctx, name); + assert!( + saved_task(&saved, "complete", "t_complete") + .get("purpose") + .is_none() + ); + assert!(ctx.run_command("r complete t nope purpose set x").is_err()); + } + + #[test] + fn test_options_env_override() { + let name = "options_env_override"; + let (mut ctx, _defer) = TestContext::new(name); + ctx.assert_command_success("o env override allow"); + ctx.assert_command_success("r complete t t_complete o env override deny"); + ctx.assert_command_success("r fresh create"); + ctx.assert_command_success("r fresh o env override allow"); + let saved = saved_policy(&mut ctx, name); + assert_eq!(saved["options"]["env"]["override_behavior"], true); + assert_eq!( + saved_task(&saved, "complete", "t_complete")["options"]["env"]["override_behavior"], + false + ); + assert_eq!( + saved_role(&saved, "fresh")["options"], + serde_json::json!({"env": {"override_behavior": true}}) + ); + ctx.assert_command_success("o env override unset"); + assert!( + saved_policy(&mut ctx, name)["options"]["env"] + .get("override_behavior") + .is_none() + ); + } + + #[test] + fn test_commands_with_checks() { + let name = "commands_with_checks"; + let (mut ctx, _defer) = TestContext::new(name); + let hash = "a".repeat(64); + ctx.assert_command_success(&format!( + "r complete t t_complete cmd whitelist add --sha256 {hash} --read-only /usr/bin/id -u" + )); + ctx.assert_command_success( + "r complete t t_complete cmd blacklist add --immutable /usr/bin/rm", + ); + let saved = saved_policy(&mut ctx, name); + let commands = &saved_task(&saved, "complete", "t_complete")["commands"]; + assert!(commands["add"].as_array().unwrap().contains( + &serde_json::json!({"command": "/usr/bin/id -u", "sha256": hash, "read_only": true}) + )); + assert!( + commands["del"] + .as_array() + .unwrap() + .contains(&serde_json::json!({"command": "/usr/bin/rm", "immutable": true})) + ); + // the same command line in either form is the same command + assert!( + ctx.run_command("r complete t t_complete cmd whitelist add /usr/bin/id -u") + .is_err() + ); + ctx.assert_command_success("r complete t t_complete cmd whitelist del /usr/bin/id -u"); + let saved = saved_policy(&mut ctx, name); + let add = &saved_task(&saved, "complete", "t_complete")["commands"]["add"]; + assert!(!add.to_string().contains("/usr/bin/id -u"), "{add}"); + // a hash of the wrong length, or not hexadecimal + for command in [ + "r complete t t_complete cmd whitelist add --sha256 abcd /usr/bin/ls", + "r complete t t_complete cmd whitelist add --sha512 zz /usr/bin/ls", + ] { + assert!(ctx.run_command(command).is_err(), "{command}"); + } + } + /// The password option of `level`, as (tries, delay in seconds, unknown fields). fn password(ctx: &TestContext, level: Level) -> Option<(Option, Option, usize)> { ctx.opt(level) diff --git a/src/chsr/cli/pair.rs b/src/chsr/cli/pair.rs index 64973996..0a1f230c 100644 --- a/src/chsr/cli/pair.rs +++ b/src/chsr/cli/pair.rs @@ -18,7 +18,9 @@ use rootasrole_core::{ util::{StorageMethod, is_valid_env_name}, }; -use super::data::{InputAction, Inputs, PasswordOpt, Rule, SetListType, TimeoutOpt}; +use super::data::{ + EnvOverride, InputAction, Inputs, PasswordOpt, RoleLink, Rule, SetListType, TimeoutOpt, +}; type MatchingFunction = dyn Fn(&Pair, &mut Inputs) -> Result<(), Box>; @@ -270,6 +272,77 @@ fn match_pair(pair: &Pair, inputs: &mut Inputs) -> Result<(), Box { inputs.role_id = Some(pair.as_str().to_string()); } + Rule::role_link_kind => { + inputs.role_link = Some(if pair.as_str() == "ssd" { + RoleLink::Ssd + } else { + RoleLink::Parents + }); + } + Rule::linked_role => { + inputs + .role_links + .get_or_insert_default() + .push(pair.as_str().to_string()); + } + Rule::purpose_keyword => { + inputs.purpose = true; + } + Rule::purpose_text => { + // the words of the purpose, unquoted like the arguments of a command + inputs.task_purpose = Some(shell_words::split(pair.as_str())?.join(" ")); + } + Rule::cmd_hash => { + let mut inner = pair.clone().into_inner(); + let kind = inner + .next() + .ok_or("a hash type is needed")? + .as_str() + .trim_start_matches('-') + .to_string(); + let hash = inner + .next() + .ok_or("a hash is needed")? + .as_str() + .to_ascii_lowercase(); + let length = match kind.as_str() { + "sha224" => 56, + "sha256" => 64, + "sha384" => 96, + "sha512" => 128, + other => return Err(format!("Unknown hash type: {other}").into()), + }; + if !hash.bytes().all(|b| b.is_ascii_hexdigit()) { + return Err(format!("A {kind} hash is hexadecimal: {hash}").into()); + } + if hash.len() != length { + return Err(format!( + "A {kind} hash has {length} hexadecimal digits, this one has {}", + hash.len() + ) + .into()); + } + inputs.cmd_checks.hash = Some((kind, hash)); + } + Rule::cmd_check_flag => { + if pair.as_str() == "--read-only" { + inputs.cmd_checks.read_only = true; + } else { + inputs.cmd_checks.immutable = true; + } + } + Rule::opt_env_override => { + // unset, unless a value follows + inputs.options_env_override = Some(EnvOverride::Unset); + } + Rule::env_override_value => { + inputs.action = InputAction::Set; + inputs.options_env_override = Some(if pair.as_str() == "allow" { + EnvOverride::Allow + } else { + EnvOverride::Deny + }); + } Rule::role_type_arg => { if pair.as_str() == "all" { inputs.role_type = Some(RoleType::All); diff --git a/src/chsr/cli/process.rs b/src/chsr/cli/process.rs index f340c862..5110f26a 100644 --- a/src/chsr/cli/process.rs +++ b/src/chsr/cli/process.rs @@ -6,10 +6,10 @@ use std::{cell::RefCell, error::Error, ffi::OsStr, path::Path, rc::Rc}; use json::{ cmd_setpolicy, cmd_whitelist_action, cred_caps, cred_set, cred_setpolicy, cred_unset, - env_set_policylist, env_setlist_add, env_setpolicy, env_whitelist_set, grant_revoke, - path_purge, path_set, path_setlist2, path_setpolicy, role_add_del, set_authentication, - set_bounding, set_execinfo, set_password, set_privileged, set_timeout, set_umask, task_add_del, - unset_password, unset_timeout, + edit_role_links, edit_task_purpose, env_set_policylist, env_setlist_add, env_setpolicy, + env_whitelist_set, grant_revoke, path_purge, path_set, path_setlist2, path_setpolicy, + role_add_del, set_authentication, set_bounding, set_env_override, set_execinfo, set_password, + set_privileged, set_timeout, set_umask, task_add_del, unset_password, unset_timeout, }; use log::debug; @@ -110,6 +110,38 @@ pub fn process_input(storage: &mut FileSettings, inputs: Inputs) -> Result edit_role_links( + rconfig, + &role_id, + link, + action, + role_links.unwrap_or_default(), + ), + Inputs { + // chsr role r1 task t1 purpose set , chsr role r1 task t1 purpose unset + action, + role_id: Some(role_id), + task_id: Some(task_id), + purpose: true, + task_purpose, + .. + } => edit_task_purpose(rconfig, &role_id, task_id, action, task_purpose), + Inputs { + // chsr o env override allow|deny|unset + role_id, + task_id, + options_env_override: Some(value), + .. + } => set_env_override(rconfig, role_id.as_ref(), task_id, value), + Inputs { // chsr role r1 add|del action, @@ -217,8 +249,17 @@ pub fn process_input(storage: &mut FileSettings, inputs: Inputs) -> Result cmd_whitelist_action(rconfig, &role_id, task_id, cmd_id, setlist_type, action), + } => cmd_whitelist_action( + rconfig, + &role_id, + task_id, + cmd_id, + setlist_type, + action, + &cmd_checks, + ), Inputs { role_id: Some(role_id), task_id: Some(task_id), diff --git a/src/chsr/cli/process/json.rs b/src/chsr/cli/process/json.rs index 9b3f0859..05a702aa 100644 --- a/src/chsr/cli/process/json.rs +++ b/src/chsr/cli/process/json.rs @@ -5,7 +5,11 @@ use indexmap::IndexSet; use jiff::SignedDuration; use log::{debug, warn}; -use crate::cli::data::{InputAction, PasswordOpt, RoleType, SetListType, TaskType, TimeoutOpt}; +use crate::cli::data::{ + CommandChecks, EnvOverride, InputAction, PasswordOpt, RoleLink, RoleType, SetListType, + TaskType, TimeoutOpt, +}; +use serde_json::Value; use rootasrole_core::database::{ options::{ @@ -481,54 +485,168 @@ pub fn cmd_whitelist_action( cmd_id: impl IntoIterator, setlist_type: SetListType, action: InputAction, + checks: &CommandChecks, ) -> Result> { debug!("chsr role r1 task t1 command whitelist add c1"); let task = rconfig.task(role_id, task_id)?; - let cmd = SCommand::Simple(shell_words::join(cmd_id)); - match setlist_type { - SetListType::White => match action { - InputAction::Add => { - //verify if command exists - if task.as_ref().borrow().commands.add.contains(&cmd) { - return Err("Command already exists".into()); - } - task.as_ref().borrow_mut().commands.add.push(cmd); + let line = shell_words::join(cmd_id); + let mut task = task.as_ref().borrow_mut(); + let list = match setlist_type { + SetListType::White => &mut task.commands.add, + SetListType::Black => &mut task.commands.sub, + _ => return Err("Unknown list type".into()), + }; + let listed = |command: &SCommand| command_line(command) == Some(line.as_str()); + match action { + InputAction::Add => { + if list.iter().any(listed) { + return Err("Command already exists".into()); } - InputAction::Del => { - //if command is not in task, warns - if !task.as_ref().borrow().commands.add.contains(&cmd) { - println_ignore_io_error!("Command {cmd:?} not in task"); - } - task.as_ref().borrow_mut().commands.add.retain(|c| { - debug!("'{:?}' != '{:?}' : {}", c, &cmd, *c != cmd); - *c != cmd - }); + list.push(command_entry(line.clone(), checks)); + } + InputAction::Del => { + if !list.iter().any(listed) { + println_ignore_io_error!("Command {line:?} not in task"); } - _ => return Err(format!("Unknown action {action:?}").into()), - }, - SetListType::Black => match action { - InputAction::Add => { - //verify if command exists - if task.as_ref().borrow().commands.sub.contains(&cmd) { - return Err("Command already exists".into()); - } - task.as_ref().borrow_mut().commands.sub.push(cmd); + // in either form, with checks or not + list.retain(|command| !listed(command)); + } + _ => return Err(format!("Unknown action {action:?}").into()), + } + Ok(true) +} + +/// The command line of an entry of a command list: the string, or the `command` of an entry +/// with the checks of the hashchecker plugin. +fn command_line(command: &SCommand) -> Option<&str> { + match command { + SCommand::Simple(line) => Some(line), + SCommand::Complex(entry) => entry.get("command").and_then(Value::as_str), + } +} + +/// The entry of a command line: the string, or an object with the checks of the hashchecker +/// plugin. +fn command_entry(line: String, checks: &CommandChecks) -> SCommand { + if *checks == CommandChecks::default() { + return SCommand::Simple(line); + } + warn!( + "The hashchecker plugin is deprecated and will be removed in RootAsRole 5.0: \ + use the IPE LSM to restrict which files can be executed" + ); + let mut entry = serde_json::Map::new(); + entry.insert("command".into(), Value::from(line)); + if let Some((kind, hash)) = &checks.hash { + entry.insert(kind.clone(), Value::from(hash.clone())); + } + if checks.read_only { + entry.insert("read_only".into(), Value::Bool(true)); + } + if checks.immutable { + entry.insert("immutable".into(), Value::Bool(true)); + } + SCommand::Complex(Value::Object(entry)) +} + +/// `chsr role R ssd|parents add|del|set R2,R3` and `... purge`: the roles that a member of R +/// must not hold (ssd), or whose tasks R gets (parents), kept as a list of names in R. +pub fn edit_role_links( + rconfig: &Rc>, + role_id: &str, + link: RoleLink, + action: InputAction, + names: Vec, +) -> Result> { + let key = link.key(); + debug!("chsr role r1 {key} {action:?}"); + let role = rconfig.role(role_id).ok_or("Role not found")?; + if names.iter().any(|name| name == role_id) { + return Err(format!("The {key} of {role_id} cannot name {role_id} itself").into()); + } + if matches!(action, InputAction::Add | InputAction::Set) { + for name in names.iter().filter(|name| rconfig.role(name).is_none()) { + println_ignore_io_error!("Role {name} does not exist (yet)"); + } + } + let mut role = role.as_ref().borrow_mut(); + let mut current: Vec = match role.extra_fields.get(key) { + None => Vec::new(), + Some(Value::String(name)) => vec![name.clone()], + Some(Value::Array(linked)) => linked + .iter() + .map(|name| name.as_str().map(str::to_string)) + .collect::>() + .ok_or_else(|| format!("The {key} of {role_id} holds something else than names"))?, + Some(other) => { + return Err(format!("The {key} of {role_id} is not a list of roles: {other}").into()); + } + }; + match action { + InputAction::Add | InputAction::Set => { + if action == InputAction::Set { + current.clear(); } - InputAction::Del => { - //if command is not in task, warns - if !task.as_ref().borrow().commands.sub.contains(&cmd) { - println_ignore_io_error!("Command {cmd:?} not in task"); + for name in names { + if !current.contains(&name) { + current.push(name); } - task.as_ref() - .borrow_mut() - .commands - .sub - .retain(|c| c != &cmd); } - _ => return Err(format!("Unknown action {action:?}").into()), - }, - _ => return Err("Unknown list type".into()), + } + InputAction::Del => current.retain(|name| !names.contains(name)), + InputAction::Purge => current.clear(), + _ => return Err(format!("Unknown action {action:?}").into()), } + if current.is_empty() { + role.extra_fields.remove(key); + } else { + role.extra_fields + .insert(key.to_string(), Value::from(current)); + } + Ok(true) +} + +/// `chsr role R task T purpose set ` and `... purpose unset`. +pub fn edit_task_purpose( + rconfig: &Rc>, + role_id: &str, + task_id: IdTask, + action: InputAction, + purpose: Option, +) -> Result> { + debug!("chsr role r1 task t1 purpose {action:?}"); + let task = rconfig.task(role_id, task_id)?; + task.as_ref().borrow_mut().purpose = match action { + InputAction::Set => Some(purpose.ok_or("purpose set needs a text")?), + InputAction::Del => None, + _ => return Err(format!("Unknown action {action:?}").into()), + }; + Ok(true) +} + +/// `chsr o env override allow|deny|unset`: whether `dosr -E` may change the env behavior. +pub fn set_env_override( + rconfig: &Rc>, + role_id: Option<&String>, + task_id: Option, + value: EnvOverride, +) -> Result> { + debug!("chsr o env override {value:?}"); + perform_on_target_opt(rconfig, role_id, task_id, |opt: Rc>| { + let mut binding = opt.as_ref().borrow_mut(); + match value { + EnvOverride::Allow | EnvOverride::Deny => { + binding.env.get_or_insert_default().override_behavior = + Some(value == EnvOverride::Allow); + } + EnvOverride::Unset => { + if let Some(env) = binding.env.as_mut() { + env.override_behavior = None; + } + } + } + Ok(()) + })?; Ok(true) } diff --git a/src/chsr/cli/usage.rs b/src/chsr/cli/usage.rs index 7460273f..8c48b498 100644 --- a/src/chsr/cli/usage.rs +++ b/src/chsr/cli/usage.rs @@ -126,8 +126,22 @@ const USAGE_ROLE: UsageSection = UsageSection { description: "Specify one or more group combinations for grant or revoke operations.", indent: 2, }, + UsageItem { + name: "ssd [add|del|set] [roles], ssd purge", + description: "Roles that a member of this role must not hold (separation of duties).", + indent: 1, + }, + UsageItem { + name: "parents [add|del|set] [roles], parents purge", + description: "Roles whose tasks this role gets (hierarchy).", + indent: 1, + }, + ], + examples: &[ + "chsr role admin add", + "chsr role admin grant -u alice", + "chsr role dev ssd add auditor", ], - examples: &["chsr role admin add", "chsr role admin grant -u alice"], }; const USAGE_TASK: UsageSection = UsageSection { @@ -154,6 +168,11 @@ const USAGE_TASK: UsageSection = UsageSection { description: "Remove a task.", indent: 1, }, + UsageItem { + name: "purpose set [text], purpose unset", + description: "Describe what the task is for.", + indent: 1, + }, ], examples: &[ "chsr role admin task backup add", @@ -185,6 +204,16 @@ const USAGE_COMMAND: UsageSection = UsageSection { description: "Manage the blacklist for commands.", indent: 1, }, + UsageItem { + name: "--sha256 [hash], --sha224, --sha384, --sha512", + description: "With add: the file of the command must have this hash (hashchecker plugin, deprecated, removed in 5.0).", + indent: 2, + }, + UsageItem { + name: "--read-only, --immutable", + description: "With add: the file of the command must be read-only, or immutable (hashchecker plugin, deprecated).", + indent: 2, + }, ], examples: &["chsr role admin task backup command setpolicy allow-all"], }; @@ -373,6 +402,11 @@ const USAGE_OPTIONS_ENV: UsageSection = UsageSection { description: "Set variables to enforce.", indent: 1, }, + UsageItem { + name: "override [allow|deny|unset]", + description: "Whether dosr -E may change the environment behavior.", + indent: 1, + }, UsageItem { name: "keep-only [key,...]", description: "Set policy as delete-all and key map to keep.", @@ -712,6 +746,15 @@ fn rule_to_string(rule: Rule) -> String { Rule::opt_password => "password", Rule::opt_path | Rule::path => "path", Rule::opt_env => "env", + Rule::role_links | Rule::role_link_kind => "ssd, parents", + Rule::linked_roles | Rule::linked_role => "role names, separated by commas", + Rule::purpose_text => "the purpose of the task", + Rule::cmd_hash | Rule::cmd_hash_type => { + "--sha224, --sha256, --sha384 or --sha512 and a hash" + } + Rule::cmd_hash_value => "a hash in hexadecimal", + Rule::opt_env_override => "override", + Rule::env_override_value => "allow, deny", Rule::env_key | Rule::env_key_list => "a variable name, where * matches any characters", Rule::env_key_value | Rule::env_value_list => "NAME=value", Rule::opt_workdir => "workdir", @@ -759,6 +802,8 @@ const DEFAULT_USAGE_SECTIONS: &[UsageSectionId] = &[ UsageSectionId::Upgrade, ]; +const ROLE_SECTIONS: &[UsageSectionId] = &[UsageSectionId::Role]; +const TASK_SECTIONS: &[UsageSectionId] = &[UsageSectionId::Task]; const OPTIONS_GENERAL_SECTIONS: &[UsageSectionId] = &[UsageSectionId::OptionsGeneral]; const OPTIONS_AUTH_SECTIONS: &[UsageSectionId] = &[UsageSectionId::OptionsAuth]; const OPTIONS_EXECINFO_SECTIONS: &[UsageSectionId] = &[UsageSectionId::OptionsExecinfo]; @@ -818,9 +863,20 @@ const fn usage_sections_for_rule(rule: Rule) -> Option<&'static [UsageSectionId] | Rule::opt_env_set | Rule::env_key_list | Rule::env_value_list - | Rule::env_key => Some(OPTIONS_ENV_SECTIONS), + | Rule::env_key + | Rule::opt_env_override + | Rule::env_override_value => Some(OPTIONS_ENV_SECTIONS), + Rule::role_links | Rule::role_link_kind | Rule::linked_roles | Rule::linked_role => { + Some(ROLE_SECTIONS) + } + Rule::purpose_keyword | Rule::purpose_text => Some(TASK_SECTIONS), Rule::caps_listing => Some(CREDENTIALS_CAPS_SECTIONS), - Rule::cmd_checklisting => Some(COMMAND_LISTING_SECTIONS), + Rule::cmd_checklisting + | Rule::cmd_checks + | Rule::cmd_hash + | Rule::cmd_hash_type + | Rule::cmd_hash_value + | Rule::cmd_check_flag => Some(COMMAND_LISTING_SECTIONS), Rule::opt_path_listing => Some(PATH_LISTING_SECTIONS), Rule::opt_env_listing => Some(ENV_LISTING_SECTIONS), Rule::convert => Some(CONVERT_SECTIONS), From 3c6111cb5e81cb05c40d7dfbc89de412a5ee6dbe Mon Sep 17 00:00:00 2001 From: LeChatP Date: Fri, 23 Oct 2026 23:09:33 +0200 Subject: [PATCH 41/88] fix: cargo xtask install writes in the root file whether the policy is immutable * fix: set_immutable assigned the value to a copy (if let Some(mut immutable) = settings.immutable), and the template has no storage settings: the root file never said whether the policy is immutable, and dosr and chsr took the build default, true, even on a file system without immutable flags * tests: set_immutable writes true and false in the storage settings of the template Co-Authored-By: Claude --- xtask/src/configure.rs | 32 +++++++++++++++++++++++++------- 1 file changed, 25 insertions(+), 7 deletions(-) diff --git a/xtask/src/configure.rs b/xtask/src/configure.rs index 017c3f47..394f6474 100644 --- a/xtask/src/configure.rs +++ b/xtask/src/configure.rs @@ -17,8 +17,8 @@ use crate::util::{ ENV_PATH_ADD_LIST_SLICE, ENV_PATH_BEHAVIOR, ENV_PATH_REMOVE_LIST_SLICE, ENV_SET_LIST, INFO, ImmutableLock, Opt, OsTarget, PACKAGE_VERSION, PAM_CONFIG_SERVICE, Policy, RAR_AUTHENTICATION, RAR_BOUNDING, RAR_CFG_DATA_PATH, RAR_CFG_IMMUTABLE, RAR_CFG_PATH, RAR_CFG_TYPE, - RAR_USER_CONSIDERED, RootSettings, SEnvOptions, SPathOptions, STimeout, SWorkdirSet, - TIMEOUT_DURATION, TIMEOUT_MAX_USAGE, TIMEOUT_TYPE, UMASK, WORKDIR_ADD_LIST_SLICE, + RAR_USER_CONSIDERED, RemoteStorageSettings, RootSettings, SEnvOptions, SPathOptions, STimeout, + SWorkdirSet, TIMEOUT_DURATION, TIMEOUT_MAX_USAGE, TIMEOUT_TYPE, UMASK, WORKDIR_ADD_LIST_SLICE, WORKDIR_BEHAVIOR, WORKDIR_FALLBACK, WORKDIR_REMOVE_LIST_SLICE, cap_effective, toggle_lock_config, }; @@ -65,11 +65,17 @@ pub fn deploy_config_file() -> Result { } fn set_immutable(config: &mut RootSettings, value: bool) { - if let Some(settings) = config.storage.settings.as_mut() - && let Some(mut _immutable) = settings.immutable - { - _immutable = value; - } + // written in the root file: without it, dosr and chsr take the build default (true), even + // on a file system without immutable flags; the value was set on a copy before + config + .storage + .settings + .get_or_insert_with(|| RemoteStorageSettings { + immutable: None, + path: None, + extra_fields: Value::Object(serde_json::Map::new()), + }) + .immutable = Some(value); if !value { let roles = config @@ -464,4 +470,16 @@ mod tests { PathBuf::from("/etc/security/rootasrole.json") ); } + + #[test] + fn test_set_immutable_writes_the_value() { + let template = include_str!("../../resources/rootasrole.json") + .replace("PACKAGE_VERSION", &PACKAGE_VERSION.to_string()); + for value in [true, false] { + let mut settings: RootSettings = serde_json::from_str(&template).unwrap(); + set_immutable(&mut settings, value); + let written = serde_json::to_value(&settings).unwrap(); + assert_eq!(written["storage"]["settings"]["immutable"], value); + } + } } From fdbbbe029b972b9f46a8ded3d4dc0c33c038b072 Mon Sep 17 00:00:00 2001 From: LeChatP Date: Fri, 23 Oct 2026 23:11:19 +0200 Subject: [PATCH 42/88] tests: the SSD check of e2e.sh no longer hides the check of a planted policy file * tests: the role of the SSD check allowed /usr/bin/id -u as the user, the least privileged task for the command that planted_policy_is_refused runs: dosr chose it with or without the planted file, and the check of the immutable flag could not tell; the SSD check runs id -g Co-Authored-By: Claude --- tests/vagrant/e2e.sh | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/tests/vagrant/e2e.sh b/tests/vagrant/e2e.sh index f4e5ee02..2faba8f6 100755 --- a/tests/vagrant/e2e.sh +++ b/tests/vagrant/e2e.sh @@ -120,7 +120,7 @@ setup() { "name": "e2e-guarded", "actors": [{ "user": "$TEST_USER" }], "ssd": ["e2e-other"], - "tasks": [{ "name": "uid", "commands": ["/usr/bin/id ^-u$"] }] + "tasks": [{ "name": "gid", "commands": ["/usr/bin/id ^-g$"] }] }, { "name": "e2e-other", "actors": [{ "group": "$BIG_GROUP" }], @@ -291,14 +291,15 @@ password_is_asked_again() { # a failed lookup of an actor disables the roles with a separation of duties: the members of # a group take more than the 1 MiB that the lookups of dosr allow, getgrnam_r fails with ERANGE ssd_role_disabled_by_a_failed_lookup() { - local uid before after members since - uid=$(id -u "$TEST_USER") + local gid before after members since + # not id -u, which planted_policy_is_refused checks: this role would be chosen for it + gid=$(id -g "$TEST_USER") cp -p /etc/group "$GROUP_BACKUP" && cp -p /etc/gshadow "$GSHADOW_BACKUP" || return 1 groupadd "$BIG_GROUP" || { restore_groups return 1 } - before=$(as_user dosr -r e2e-guarded -t uid /usr/bin/id -u 2>/dev/null) + before=$(as_user dosr -r e2e-guarded -t gid /usr/bin/id -g 2>/dev/null) # one argument is limited to 128 KiB: the member list goes through a file members=$(mktemp) seq -f 'rar%07g' 1 120000 | paste -sd, >"$members" @@ -307,10 +308,10 @@ ssd_role_disabled_by_a_failed_lookup() { /etc/group >"$members.group" cat "$members.group" >/etc/group since=$(date +%s) - after=$(as_user dosr -r e2e-guarded -t uid /usr/bin/id -u 2>/dev/null) + after=$(as_user dosr -r e2e-guarded -t gid /usr/bin/id -g 2>/dev/null) rm -f "$members" "$members.group" restore_groups - [ "$before" = "$uid" ] && [ "$after" != "$uid" ] || return 1 + [ "$before" = "$gid" ] && [ "$after" != "$gid" ] || return 1 # the refusal comes from the lookup, when the journal tells if command -v journalctl >/dev/null; then journalctl -q --since "@$since" | grep -q "Cannot look up the group $BIG_GROUP" From dd1f1c7816d6b571ed8b59f5dedc9947c92ee00f Mon Sep 17 00:00:00 2001 From: LeChatP Date: Sat, 24 Oct 2026 09:52:50 +0200 Subject: [PATCH 43/88] fix: cargo xtask --dry-run changes nothing, and uninstall needs --apply * fix: --dry-run was parsed but never applied, nothing called set_dry_run: a dry run of install elevated through the real privilege escalator (dosr) and ran the installation; run() applies it now * fix: in a dry run, the commands that change something (package manager, build, privilege escalation, manual pages) are printed instead of run, by run_checked and status_checked; the queries still run * fix: install, configure and uninstall print what they would change (the copy of the binaries, their owner, mode and capabilities, the PAM service, the policy) and stop, also when xtask runs as root and does not elevate * fix: uninstall removed the binaries and the PAM service without --apply, which its help says is required; without it, it prints what it would remove * tests: dry runs of install, install -bi, dependencies -i and configure, with a program that leaves a mark as the privilege escalation, never run it, and the same program does leave its mark when it runs; uninstall applies only with --apply and outside a dry run Co-Authored-By: Claude --- xtask/src/installer/mod.rs | 152 ++++++++++++++++++++++++++++++++----- xtask/src/main.rs | 65 ++++++++++++++-- xtask/src/util.rs | 18 ++++- 3 files changed, 211 insertions(+), 24 deletions(-) diff --git a/xtask/src/installer/mod.rs b/xtask/src/installer/mod.rs index 6f6e6e3b..6077d16d 100644 --- a/xtask/src/installer/mod.rs +++ b/xtask/src/installer/mod.rs @@ -4,7 +4,7 @@ pub mod install; mod uninstall; use std::fmt::Write; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::str::FromStr; use std::{collections::VecDeque, fmt::Display}; @@ -14,12 +14,16 @@ use jiff::civil::Date; use semver::Version; use anyhow::anyhow; -use log::debug; +use capctl::{Cap, CapState}; +use log::{debug, info}; use crate::util::path_exe_from_env; use crate::{ configure, - util::{OsTarget, detect_priv_bin, get_os, is_dry_run}, + util::{ + OsTarget, PAM_CONFIG_SERVICE, RAR_CFG_DATA_PATH, RAR_CFG_PATH, detect_priv_bin, get_os, + is_dry_run, + }, }; pub const RAR_BIN_PATH: &str = env!("RAR_BIN_PATH"); pub const SR_DEST: &str = "dosr"; @@ -277,32 +281,78 @@ impl FromStr for Toolchain { } } +/// What `configure` would change, for a dry run. +fn configure_plan() -> Vec { + vec![ + format!("deploy /etc/pam.d/{PAM_CONFIG_SERVICE} if it is missing or empty"), + format!( + "deploy the policy, {RAR_CFG_PATH} and {RAR_CFG_DATA_PATH}, if missing, immutable when the file system allows it, then run chsr upgrade" + ), + ] +} + +fn print_plan(what: &str, plan: &[String]) { + info!("Dry run, nothing is changed: {what} would"); + for step in plan { + info!(" - {step}"); + } +} + pub fn configure(os: Option) -> Result<(), anyhow::Error> { if is_dry_run() { - debug!("Dry-run mode: skipping configure changes"); + print_plan("configure", &configure_plan()); return Ok(()); } configure::configure(os) } +/// In a dry run, the commands of the package manager are printed, not run (`run_checked`). pub fn dependencies(opts: &InstallDependenciesOptions) -> Result<(), anyhow::Error> { - if is_dry_run() { - debug!("Dry-run mode: skipping dependencies installation"); - return Ok(()); - } dependencies::install(opts) } -pub fn install(opts: &InstallOptions) -> Result<(), anyhow::Error> { - if is_dry_run() { - debug!("Dry-run mode: skipping install changes"); - return Ok(()); +/// What the privileged part of `install` would change, for a dry run: these steps change the +/// system without a command that a dry run could print. +fn install_plan(opts: &InstallOptions, priv_bin: Option<&Path>) -> Vec { + let mut plan = Vec::new(); + let privileged = CapState::get_current().is_ok_and(|state| { + [Cap::DAC_OVERRIDE, Cap::CHOWN, Cap::SETFCAP] + .into_iter() + .all(|cap| state.permitted.has(cap)) + }); + if !privileged { + let exe = std::env::current_exe() + .map_or_else(|_| "xtask".into(), |exe| exe.display().to_string()); + plan.push( + priv_bin + .map(Path::to_path_buf) + .or_else(detect_priv_bin) + .map_or_else( + || "stop: no privilege escalation (dosr, sudo, doas, please) was found".into(), + |priv_bin| { + format!( + "run {} {exe} install --nested-install, to do the rest with privileges", + priv_bin.display() + ) + }, + ), + ); } - if opts.nested_install { - unsafe { std::env::set_var("ROOTASROLE_INSTALLER_NESTED", "1") }; - } else { - unsafe { std::env::remove_var("ROOTASROLE_INSTALLER_NESTED") }; + let profile = opts.build_opts.profile; + plan.push(format!( + "copy target/{profile}/{SR_DEST} and target/{profile}/{CHSR_DEST} into {RAR_BIN_PATH} as new files of root, mode 0555" + )); + plan.push(format!( + "give {RAR_BIN_PATH}/{SR_DEST} the file capabilities =p" + )); + plan.extend(configure_plan()); + if opts.clean_after { + plan.push("run cargo clean".into()); } + plan +} + +pub fn install(opts: &InstallOptions) -> Result<(), anyhow::Error> { let os = get_os(opts.os.as_ref())?; let priv_bin = opts .build_opts @@ -332,6 +382,15 @@ pub fn install(opts: &InstallOptions) -> Result<(), anyhow::Error> { debug!("Building sr and chsr"); build(&opts.build_opts)?; } + if is_dry_run() { + print_plan("install", &install_plan(opts, priv_bin.as_deref())); + return Ok(()); + } + if opts.nested_install { + unsafe { std::env::set_var("ROOTASROLE_INSTALLER_NESTED", "1") }; + } else { + unsafe { std::env::remove_var("ROOTASROLE_INSTALLER_NESTED") }; + } if install::install( priv_bin.as_deref(), opts.build_opts.profile, @@ -350,10 +409,67 @@ pub fn build(opts: &BuildOptions) -> Result<(), anyhow::Error> { build::build(opts) } +/// What `uninstall` would remove, for a dry run or without `--apply`. +fn uninstall_plan(opts: &UninstallOptions) -> Vec { + let mut plan = Vec::new(); + if opts.kind.is_all() || opts.kind.is_sr() { + plan.push(format!( + "remove {RAR_BIN_PATH}/{SR_DEST} and {RAR_BIN_PATH}/{CHSR_DEST}" + )); + plan.push(if opts.clean_config { + format!("remove /etc/pam.d/{PAM_CONFIG_SERVICE}") + } else { + format!("remove /etc/pam.d/{PAM_CONFIG_SERVICE} if it is the one that install deploys") + }); + if opts.clean_config { + plan.push(format!( + "remove the immutable flag of {RAR_CFG_PATH} and {RAR_CFG_DATA_PATH}, and remove {RAR_CFG_PATH}" + )); + } + } + plan +} + +/// Whether uninstall changes the system: `--apply` is required, and a dry run never does. +fn uninstall_applies(opts: &UninstallOptions) -> bool { + opts.apply && !is_dry_run() +} + pub fn uninstall(opts: &UninstallOptions) -> Result<(), anyhow::Error> { - if is_dry_run() { - debug!("Dry-run mode: skipping uninstall changes"); + if !uninstall_applies(opts) { + print_plan("uninstall", &uninstall_plan(opts)); + if !opts.apply { + info!("Add --apply to uninstall"); + } return Ok(()); } uninstall::uninstall(opts) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::util::{DRY_RUN_TEST_LOCK, set_dry_run}; + + #[test] + fn test_uninstall_needs_apply_and_no_dry_run() { + let _guard = DRY_RUN_TEST_LOCK.lock().unwrap(); + let opts = |apply| UninstallOptions { + clean_config: false, + apply, + kind: UninstallKind::All, + }; + set_dry_run(false); + assert!(!uninstall_applies(&opts(false))); + assert!(uninstall_applies(&opts(true))); + set_dry_run(true); + assert!(!uninstall_applies(&opts(true))); + set_dry_run(false); + let plan = uninstall_plan(&opts(false)).join("\n"); + assert!( + plan.contains(&format!("{RAR_BIN_PATH}/{SR_DEST}")), + "{plan}" + ); + assert!(!plan.contains(RAR_CFG_PATH), "{plan}"); + } +} diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 87c713c8..af876483 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -54,18 +54,73 @@ fn main() { if std::env::var_os("ROOTASROLE_INSTALLER_NESTED").is_some() { info!("nested install is enabled"); } - let opts = Options::parse(); - let ret = match opts.command { + if let Err(e) = run(Options::parse()) { + error!("{e:#}"); + exit(1); + } +} + +/// Run the command of `opts`. `--dry-run` was parsed but never applied: a dry run of install +/// elevated through the real privilege escalator. +fn run(opts: Options) -> Result<(), anyhow::Error> { + util::set_dry_run(opts.dry_run); + match opts.command { Dependencies(opts) => installer::dependencies(&opts), Build(opts) => installer::build(&opts), Install(opts) => installer::install(&opts), Configure { os } => installer::configure(os), Uninstall(opts) => installer::uninstall(&opts), Doctor(opts) => doctor::doctor(&opts), + } +} + +#[cfg(test)] +mod tests { + use std::{ + fs, + os::unix::fs::PermissionsExt, + path::{Path, PathBuf}, }; - if let Err(e) = ret { - error!("{e:#}"); - exit(1); + use super::*; + + /// A program that leaves a mark when it runs, given as the privilege escalation of the + /// tests: even a dry run that went wrong would run it, never sudo nor dosr. + fn marker_program(dir: &Path) -> (PathBuf, PathBuf) { + let program = dir.join("escalate"); + fs::write(&program, "#!/bin/sh\ntouch \"$0.ran\"\n").unwrap(); + fs::set_permissions(&program, fs::Permissions::from_mode(0o755)).unwrap(); + (program, dir.join("escalate.ran")) + } + + #[test] + fn test_dry_run_runs_no_command() { + let _guard = util::DRY_RUN_TEST_LOCK.lock().unwrap(); + let dir = std::env::temp_dir().join(format!("xtask_dry_run_{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + let (program, mark) = marker_program(&dir); + let program_arg = program.to_str().unwrap(); + for args in [ + vec!["xtask", "--dry-run", "install", "-p", program_arg], + vec!["xtask", "install", "--dry-run", "-bi", "-p", program_arg], + vec![ + "xtask", + "--dry-run", + "dependencies", + "-i", + "-p", + program_arg, + ], + vec!["xtask", "--dry-run", "configure"], + ] { + run(Options::parse_from(&args)).unwrap(); + assert!(!mark.exists(), "{args:?} ran the privilege escalation"); + } + // the program does leave its mark when it is run: the check above can fail + util::set_dry_run(false); + util::run_checked(&mut std::process::Command::new(&program), "leave a mark").unwrap(); + assert!(mark.exists()); + let _ = fs::remove_dir_all(&dir); } } diff --git a/xtask/src/util.rs b/xtask/src/util.rs index 3e5009aa..204d8a42 100644 --- a/xtask/src/util.rs +++ b/xtask/src/util.rs @@ -4,6 +4,7 @@ use std::{ fs::{self, File}, io, num::ParseIntError, + os::unix::process::ExitStatusExt, os::{fd::AsRawFd, unix::fs::MetadataExt}, path::{Path, PathBuf}, process::{Command, ExitStatus, Output}, @@ -621,6 +622,10 @@ pub const INFO: SInfo = SInfo::const_parse(env!("RAR_EXEC_INFO_DISPLAY")); static DRY_RUN: AtomicBool = AtomicBool::new(false); +/// Held by the tests that change `DRY_RUN`, which is global. +#[cfg(test)] +pub static DRY_RUN_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + #[derive(Debug)] pub enum ImmutableLock { Set, @@ -779,6 +784,15 @@ pub fn is_dry_run() -> bool { /// /// Will return an error if the command fails to execute or exits with a non-zero code pub fn status_checked(command: &mut Command, action: &str) -> Result { + // the commands run through here change something (packages, build, privileges): a dry run + // prints them instead; output_checked runs the queries, which change nothing + if is_dry_run() { + info!( + "{BOLD}Dry run, not running:{RST} {}\n{BOLD} Objective -->{RST} {action}", + shell_quote_command(command) + ); + return Ok(ExitStatus::from_raw(0)); + } let status = command .status() .with_context(|| format!("Failed to {action}: {command:?}"))?; @@ -814,7 +828,9 @@ fn shell_quote_command(command: &Command) -> String { /// /// Will return an error if the command fails to execute or exits with a non-zero code pub fn run_checked(command: &mut Command, action: &str) -> Result<(), anyhow::Error> { - log_command_execution(command, action); + if !is_dry_run() { + log_command_execution(command, action); + } let _ = status_checked(command, action)?; Ok(()) } From 4c6e766b508b2c722498c5fb3275d71a7ab996f9 Mon Sep 17 00:00:00 2001 From: LeChatP Date: Sat, 24 Oct 2026 09:53:56 +0200 Subject: [PATCH 44/88] fix: a dry run of the dependencies does not say that they were installed Co-Authored-By: Claude --- xtask/src/installer/dependencies.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/xtask/src/installer/dependencies.rs b/xtask/src/installer/dependencies.rs index 4f764c66..d031a602 100644 --- a/xtask/src/installer/dependencies.rs +++ b/xtask/src/installer/dependencies.rs @@ -257,6 +257,8 @@ pub fn install(opts: &InstallDependenciesOptions) -> Result<(), anyhow::Error> { let deps: Vec<&str> = deps.iter().map(std::convert::AsRef::as_ref).collect(); install_dependencies(&os, &deps, priv_bin.as_deref())?; - info!("Dependencies installed successfully"); + if !util::is_dry_run() { + info!("Dependencies installed successfully"); + } Ok(()) } From 9f7555b0feaebec80fe92ce5ae0b15f13a775fcf Mon Sep 17 00:00:00 2001 From: LeChatP Date: Sat, 24 Oct 2026 10:09:24 +0200 Subject: [PATCH 45/88] feat: xtask checks the packages to build and to run RootAsRole, it no longer installs them * feat: build and install -b stop before building when a build package is missing, with the packages and the command that installs them; install without -b checks the runtime packages; a dry run only warns, an OS that is not detected is not checked * feat: the check only reads the package database of the detected OS: dpkg-query (status installed), rpm -q --whatprovides, pacman -T * feat: doctor lists the missing packages to run and to build * chore: the dependencies command is removed, it ran the package manager as root (apt-get update, pacman -Syu --noconfirm, dnf update -y); install -i is hidden, does nothing and warns, so install -bip keeps working * fix: deps.json without clang and openssl, which nothing uses (no bindgen, clang-sys nor openssl in Cargo.lock), with gcc and pkg-config, and libseccomp2 instead of libseccomp-dev as a runtime package of Debian * tests: the provision script of the VMs and the workflows install the packages, then run install -bp Co-Authored-By: Claude --- .github/workflows/build.yml | 6 +- .github/workflows/install-tests.yml | 13 +- .github/workflows/quality.yml | 6 +- .github/workflows/tests.yml | 2 +- tests/vagrant/provision.sh | 11 +- xtask/src/doctor.rs | 15 +- xtask/src/installer/dependencies.rs | 316 ++++++++++++++++------------ xtask/src/installer/deps.json | 53 ++--- xtask/src/installer/mod.rs | 54 ++--- xtask/src/main.rs | 13 +- 10 files changed, 246 insertions(+), 243 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f69055a0..c038555f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -28,8 +28,12 @@ jobs: session [success=1 default=ignore] pam_permit.so session requisite pam_permit.so session required pam_permit.so" | tee /etc/pam.d/dosr' + - name: Install Dependencies + run: | + sudo apt-get update + sudo apt-get install -y pkg-config libpam0g-dev libseccomp-dev pandoc - name: Install RootAsRole - run: cargo xtask install -bip sudo + run: cargo xtask install -bp sudo - name: print config run: dosr cat /etc/security/rootasrole.json - name: getenv diff --git a/.github/workflows/install-tests.yml b/.github/workflows/install-tests.yml index 3c61e1c3..aa24f437 100644 --- a/.github/workflows/install-tests.yml +++ b/.github/workflows/install-tests.yml @@ -20,7 +20,7 @@ jobs: - name: Install git and build dependencies run: | apt update -y - apt install -y git curl build-essential pkg-config libpam0g-dev + apt install -y git curl build-essential pkg-config libpam0g-dev libseccomp-dev pandoc apt install -y sudo ca-certificates - name: Checkout code @@ -47,7 +47,7 @@ jobs: - name: Install RootAsRole run: | - cargo xtask install --debug -bip sudo + cargo xtask install --debug -bp sudo - name: Verify installation run: | @@ -71,8 +71,7 @@ jobs: dnf config-manager --set-enabled crb || true dnf install -y epel-release || true dnf makecache -y - dnf install -y pandoc || true - dnf install -y git gcc pkg-config pam-devel + dnf install -y git gcc pkg-config pam-devel libseccomp-devel pandoc dnf install -y sudo ca-certificates - name: Checkout code @@ -99,7 +98,7 @@ jobs: - name: Install RootAsRole run: | - cargo xtask install -bip sudo + cargo xtask install -bp sudo - name: Verify installation run: | @@ -126,7 +125,7 @@ jobs: - name: Install git and build dependencies run: | zypper refresh - zypper install -y bash rustup git curl gcc pkg-config pam-devel + zypper install -y bash rustup git curl gcc pkg-config pam-devel libseccomp-devel pandoc zypper install -y sudo rustup toolchain install stable rustup update @@ -152,7 +151,7 @@ jobs: - name: Install RootAsRole run: | - cargo xtask install -bip sudo + cargo xtask install -bp sudo - name: Verify installation run: | diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index abf7e534..e4d28b61 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -40,8 +40,10 @@ jobs: components: clippy override: false - - name: Install RootAsRole - run: cargo xtask dependencies -dip sudo + - name: Install Dependencies + run: | + sudo apt-get update + sudo apt-get install -y pkg-config libpam0g-dev libseccomp-dev pandoc # if pull request review only - uses: mbrobbel/rustfmt-check@master diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 6c9f4c46..1a58c727 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -39,7 +39,7 @@ jobs: session required pam_permit.so" | tee /etc/pam.d/dosr' - name: Install Dependencies - run: cargo xtask dependencies -dip sudo + run: apt install -y pkg-config libpam0g-dev libseccomp-dev pandoc - name: run integration coverage run: cargo +nightly coverage diff --git a/tests/vagrant/provision.sh b/tests/vagrant/provision.sh index a4ef1b2c..d6149c69 100755 --- a/tests/vagrant/provision.sh +++ b/tests/vagrant/provision.sh @@ -1,5 +1,6 @@ #!/bin/bash # Build and install the copied tree in the virtual machine, as root (see the Vagrantfile). +# xtask checks the packages to build RootAsRole and does not install them: they come from here. set -euo pipefail . /etc/os-release @@ -7,10 +8,12 @@ case "$ID" in debian | ubuntu) export DEBIAN_FRONTEND=noninteractive apt-get update - apt-get install -y build-essential curl git pkg-config e2fsprogs libcap2-bin python3 + apt-get install -y build-essential curl git pkg-config e2fsprogs libcap2-bin python3 \ + libpam0g-dev libseccomp-dev pandoc ;; arch) - pacman -Syu --noconfirm --needed base-devel curl git e2fsprogs libcap python + pacman -Syu --noconfirm --needed base-devel curl git e2fsprogs libcap python \ + pam libseccomp pandoc-cli ;; *) echo "provision.sh: unsupported distribution $ID" >&2 @@ -26,10 +29,10 @@ sudo -u vagrant -H bash -c ' command -v "$HOME/.cargo/bin/cargo" >/dev/null || curl --proto "=https" --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal ' -# the other dependencies come from xtask (-i), sudo is the privilege escalation it uses (-p) +# sudo is the privilege escalation that xtask uses (-p) sudo -u vagrant -H bash -c ' set -euo pipefail . "$HOME/.cargo/env" cd "$HOME/RootAsRole" - cargo xtask install -bip sudo + cargo xtask install -bp sudo ' diff --git a/xtask/src/doctor.rs b/xtask/src/doctor.rs index 101c5061..339b5b69 100644 --- a/xtask/src/doctor.rs +++ b/xtask/src/doctor.rs @@ -6,7 +6,10 @@ use std::{ use clap::Parser; use log::{info, warn}; -use crate::util::{RAR_CFG_PATH, detect_priv_bin, get_os}; +use crate::{ + installer::dependencies, + util::{RAR_CFG_PATH, detect_priv_bin, get_os}, +}; #[derive(Debug, Parser)] pub struct DoctorOptions { @@ -66,8 +69,16 @@ pub fn doctor(opts: &DoctorOptions) -> Result<(), anyhow::Error> { } } + for (build, what) in [(false, "run"), (true, "build")] { + match dependencies::missing_packages(&os, build) { + Ok(missing) if missing.is_empty() => info!("Found the packages to {what} RootAsRole"), + Ok(missing) => warn!("{}", dependencies::missing_message(&os, build, &missing)?), + Err(e) => warn!("The packages to {what} RootAsRole cannot be checked: {e:#}"), + } + } + if opts.full { - for command in ["pandoc", "gzip", "upx"] { + for command in ["gzip", "upx"] { if command_exists(command) { info!("Found optional command: {command}"); } else { diff --git a/xtask/src/installer/dependencies.rs b/xtask/src/installer/dependencies.rs index d031a602..5f659b07 100644 --- a/xtask/src/installer/dependencies.rs +++ b/xtask/src/installer/dependencies.rs @@ -1,17 +1,14 @@ -use std::{borrow::Cow, collections::HashMap, path::Path, process::ExitStatus, sync::OnceLock}; +//! The packages that `RootAsRole` needs to build and to run. xtask checks them and prints the +//! command that installs the missing ones, it does not install them: like Rust, they are left +//! to the administrator. -use anyhow::Context; -use capctl::CapState; -use log::info; -use nix::unistd::geteuid; -use serde::Deserialize; +use std::{borrow::Cow, collections::HashMap, io, process::Command, sync::OnceLock}; -use crate::{ - installer::OsTarget, - util::{self, detect_priv_bin, get_os, path_exe_from_env, run_checked, status_checked}, -}; +use anyhow::{Context, anyhow}; +use log::warn; +use serde::Deserialize; -use super::InstallDependenciesOptions; +use crate::{installer::OsTarget, util}; #[derive(Debug, Deserialize)] struct DependenciesManifest<'a> { @@ -22,15 +19,45 @@ struct DependenciesManifest<'a> { struct TargetDependencies<'a> { #[serde(default)] aliases: Vec>, - package_manager: PackageManager<'a>, + /// How the installed packages are found + query: PackageQuery, + /// The command that installs packages, printed for the administrator, never run + install: Vec>, runtime: Vec>, development: Vec>, } -#[derive(Debug, Deserialize)] -struct PackageManager<'a> { - refresh: Vec>, - install: Vec>, +/// How a distribution tells whether a package is installed. The queries change nothing. +#[derive(Debug, Deserialize, Clone, Copy, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +enum PackageQuery { + Dpkg, + Rpm, + Pacman, +} + +impl PackageQuery { + fn is_installed(self, package: &str) -> io::Result { + let output = match self { + // one line for each architecture of a multiarch package + Self::Dpkg => Command::new("dpkg-query") + .args(["-W", "-f=${db:Status-Status}\\n", package]) + .output()?, + // a capability as well as a package name: pkgconf-pkg-config provides pkgconfig + Self::Rpm => Command::new("rpm") + .args(["-q", "--quiet", "--whatprovides", package]) + .output()?, + // a package that another one provides is satisfied too: pkgconf provides pkg-config + Self::Pacman => Command::new("pacman").args(["-T", package]).output()?, + }; + // a removed package keeps its configuration files: dpkg-query finds it, not installed + Ok(output.status.success() + && (self != Self::Dpkg + || output + .stdout + .split(|&byte| byte == b'\n') + .any(|line| line == b"installed"))) + } } fn dependencies_manifest() -> Result<&'static DependenciesManifest<'static>, anyhow::Error> { @@ -119,146 +146,159 @@ fn resolve_target<'a>( .ok_or_else(|| anyhow::anyhow!("Unsupported OS target in deps.json: {key}")) } -fn compose_command( - priv_bin: Option<&Path>, - base: &[Cow<'_, str>], -) -> Result, anyhow::Error> { - if base.is_empty() { - return Err(anyhow::anyhow!( - "Invalid package-manager command in deps.json" - )); - } - - let mut command = Vec::new(); - if is_priv_bin_necessary()? { - if let Some(priv_bin) = priv_bin { - if util::is_su_command(priv_bin) { - let shell_command = base - .iter() - .map(|arg| shell_quote(arg.as_ref())) - .collect::>() - .join(" "); - command.push(priv_bin.to_string_lossy().into_owned()); - command.push("-c".to_string()); - command.push(shell_command); - return Ok(command); - } else if util::is_run0_command(priv_bin) { - command.push(priv_bin.to_string_lossy().into_owned()); - command.push("--pipe".to_string()); - command.extend(base.iter().map(|s| s.as_ref().to_string())); - return Ok(command); - } - command.push(priv_bin.to_string_lossy().into_owned()); - } else { - return Err(anyhow::anyhow!("Privileged binary is required")); - } - } - command.extend(base.iter().map(|s| s.as_ref().to_string())); - Ok(command) -} - -fn shell_quote(arg: &str) -> String { - if arg - .chars() - .all(|c| c.is_ascii_alphanumeric() || "@%_+=:,./-".contains(c)) - { - arg.to_string() +/// The packages that `os` needs to build `RootAsRole` (`build`), or to run it, and that are not +/// installed. +/// +/// # Errors +/// When the OS is not in the manifest, or its package database cannot be queried. +pub fn missing_packages(os: &OsTarget, build: bool) -> Result, anyhow::Error> { + let target = resolve_target(dependencies_manifest()?, os)?; + let packages = if build { + &target.development } else { - format!("'{}'", arg.replace('\'', "'\\''")) + &target.runtime + }; + let mut missing = Vec::new(); + for package in packages { + if !target + .query + .is_installed(package) + .with_context(|| format!("Cannot query the packages of {os}"))? + { + missing.push(package.to_string()); + } } + Ok(missing) } -fn update_package_manager(os: &OsTarget, priv_bin: Option<&Path>) -> Result<(), anyhow::Error> { - let manifest = dependencies_manifest()?; - let target = resolve_target(manifest, os)?; - let command = compose_command(priv_bin, target.package_manager.refresh.as_slice())?; - log::info!( - "Updating package manager with command: {}", - command.join(" ") - ); - run_checked( - std::process::Command::new(&command[0]).args(&command[1..]), - "update package manager", - ) - .context("Failed to update package manager")?; - - Ok(()) +/// The command that installs `packages` on `os`, to print for the administrator. +/// +/// # Errors +/// When the OS is not in the manifest. +pub fn install_command(os: &OsTarget, packages: &[String]) -> Result { + let target = resolve_target(dependencies_manifest()?, os)?; + Ok(target + .install + .iter() + .map(AsRef::as_ref) + .chain(packages.iter().map(String::as_str)) + .collect::>() + .join(" ")) } -fn get_dependencies<'a>( - manifest: &'a DependenciesManifest<'a>, +/// The `missing` packages to build `RootAsRole` (`build`), or to run it, with the command that +/// installs them. +/// +/// # Errors +/// When the OS is not in the manifest. +pub fn missing_message( os: &OsTarget, - dev: bool, -) -> Result<&'a [Cow<'a, str>], anyhow::Error> { - let target = resolve_target(manifest, os)?; - if dev { - Ok(target.development.as_slice()) - } else { - Ok(target.runtime.as_slice()) - } + build: bool, + missing: &[String], +) -> Result { + Ok(format!( + "Missing packages to {} RootAsRole on {os}: {}. Install them, as root: {}", + if build { "build" } else { "run" }, + missing.join(" "), + install_command(os, missing)? + )) } -fn is_priv_bin_necessary() -> Result { - if geteuid().is_root() { - // as long root own files/folders, it should not need capabilities. - return Ok(false); +/// What to do with the `missing` packages: stop with the command that installs them, or only +/// warn in a dry run. +fn report_missing(os: &OsTarget, build: bool, missing: &[String]) -> Result<(), anyhow::Error> { + if missing.is_empty() { + return Ok(()); } - let mut state = CapState::get_current()?; - if state.permitted.has(capctl::Cap::DAC_OVERRIDE) - && !state.effective.has(capctl::Cap::DAC_OVERRIDE) - { - state.effective.add(capctl::Cap::DAC_OVERRIDE); - state.set_current()?; - Ok(false) + let message = missing_message(os, build, missing)?; + if util::is_dry_run() { + warn!("{message}"); + Ok(()) } else { - Ok(true) + Err(anyhow!(message)) } } -pub fn install_dependencies( - os: &OsTarget, - deps: &[&str], - priv_bin: Option<&Path>, -) -> Result { - let manifest = dependencies_manifest()?; - let target = resolve_target(manifest, os)?; +/// Check the packages that `os` needs to build `RootAsRole` (`build`), or to run it. A system +/// whose packages cannot be queried is only warned about: the build tells then. +/// +/// # Errors +/// When packages are missing, with the command that installs them. +pub fn check(os: &OsTarget, build: bool) -> Result<(), anyhow::Error> { + match missing_packages(os, build) { + Ok(missing) => report_missing(os, build, &missing), + Err(e) => { + warn!("The packages of {os} cannot be checked: {e:#}"); + Ok(()) + } + } +} - let mut command = compose_command(priv_bin, target.package_manager.install.as_slice())?; - command.extend(deps.iter().map(std::string::ToString::to_string)); +#[cfg(test)] +mod tests { + use super::*; + use crate::util::{DRY_RUN_TEST_LOCK, set_dry_run}; - status_checked( - std::process::Command::new(&command[0]).args(&command[1..]), - "install required packages", - ) -} + #[test] + fn test_every_target_has_its_packages() { + let manifest = dependencies_manifest().unwrap(); + for os in [ + OsTarget::Debian, + OsTarget::Ubuntu, + OsTarget::RedHat, + OsTarget::Fedora, + OsTarget::OpenSUSE, + OsTarget::ArchLinux, + ] { + let target = resolve_target(manifest, &os).unwrap(); + assert!(!target.install.is_empty(), "{os}"); + assert!(!target.runtime.is_empty(), "{os}"); + assert!(!target.development.is_empty(), "{os}"); + } + assert_eq!( + install_command(&OsTarget::Debian, &["gcc".into(), "pandoc".into()]).unwrap(), + "apt-get install gcc pandoc" + ); + } -pub fn install(opts: &InstallDependenciesOptions) -> Result<(), anyhow::Error> { - let os = get_os(opts.os.as_ref())?; - let priv_bin = opts - .priv_bin - .clone() - .or_else(detect_priv_bin) - .and_then(|bin| { - path_exe_from_env( - &std::env::var_os("PATH") - .unwrap_or_default() - .to_string_lossy() - .split(':') - .collect::>(), - bin, - ) - }); - update_package_manager(&os, priv_bin.as_deref())?; - // dependencies are : libpam and libseccomp - info!("Installing dependencies: libpam.so and libseccomp.so for running the application"); + #[test] + fn test_a_package_that_does_not_exist_is_missing() { + for query in [PackageQuery::Dpkg, PackageQuery::Rpm, PackageQuery::Pacman] { + match query.is_installed("rar-no-such-package") { + Ok(installed) => assert!(!installed, "{query:?}"), + // not the package manager of this system + Err(e) => assert_eq!(e.kind(), io::ErrorKind::NotFound, "{query:?}"), + } + } + } - let manifest = dependencies_manifest()?; - let deps = get_dependencies(manifest, &os, opts.dev)?; - let deps: Vec<&str> = deps.iter().map(std::convert::AsRef::as_ref).collect(); - install_dependencies(&os, &deps, priv_bin.as_deref())?; + #[test] + fn test_the_package_manager_of_this_system_is_installed() { + // the query of another system may be installed as a tool, without a package database + let Ok(os) = util::get_os(None) else { + return; + }; + let target = resolve_target(dependencies_manifest().unwrap(), &os).unwrap(); + let itself = match target.query { + PackageQuery::Dpkg => "dpkg", + PackageQuery::Rpm => "rpm", + PackageQuery::Pacman => "pacman", + }; + assert!(target.query.is_installed(itself).unwrap(), "{os}"); + } - if !util::is_dry_run() { - info!("Dependencies installed successfully"); + #[test] + fn test_missing_packages_stop_unless_dry_run() { + let _guard = DRY_RUN_TEST_LOCK.lock().unwrap(); + let missing = ["pandoc".to_string()]; + set_dry_run(false); + let error = report_missing(&OsTarget::Debian, true, &missing) + .unwrap_err() + .to_string(); + assert!(error.contains("apt-get install pandoc"), "{error}"); + assert!(report_missing(&OsTarget::Debian, true, &[]).is_ok()); + set_dry_run(true); + assert!(report_missing(&OsTarget::Debian, true, &missing).is_ok()); + set_dry_run(false); } - Ok(()) } diff --git a/xtask/src/installer/deps.json b/xtask/src/installer/deps.json index 04cf06ee..214a9888 100644 --- a/xtask/src/installer/deps.json +++ b/xtask/src/installer/deps.json @@ -2,56 +2,31 @@ "targets": { "debian": { "aliases": ["ubuntu"], - "package_manager": { - "refresh": ["apt-get", "update"], - "install": ["apt-get", "install", "-y"] - }, - "runtime": ["libpam0g", "libseccomp-dev"], - "development": [ - "libpam0g-dev", - "libclang-dev", - "pandoc", - "libseccomp-dev" - ] + "query": "dpkg", + "install": ["apt-get", "install"], + "runtime": ["libpam0g", "libseccomp2"], + "development": ["gcc", "pkg-config", "libpam0g-dev", "libseccomp-dev", "pandoc"] }, "fedora": { "aliases": ["rockylinux", "rocky", "centos", "almalinux", "rhel", "redhat"], - "package_manager": { - "refresh": ["dnf", "update", "-y"], - "install": ["dnf", "install", "-y"] - }, + "query": "rpm", + "install": ["dnf", "install"], "runtime": ["pam", "libseccomp"], - "development": [ - "clang-devel", - "openssl-devel", - "pam-devel", - "pandoc", - "libseccomp-devel" - ] + "development": ["gcc", "pkgconfig", "pam-devel", "libseccomp-devel", "pandoc"] }, "opensuse": { "aliases": ["suse"], - "package_manager": { - "refresh": ["zypper", "refresh"], - "install": ["zypper", "install", "-y"] - }, - "runtime": ["pam", "libseccomp"], - "development": [ - "clang-devel", - "libopenssl-devel", - "pam-devel", - "pandoc", - "libseccomp-devel" - ] + "query": "rpm", + "install": ["zypper", "install"], + "runtime": ["pam", "libseccomp2"], + "development": ["gcc", "pkg-config", "pam-devel", "libseccomp-devel", "pandoc"] }, "archlinux": { "aliases": ["arch"], - "package_manager": { - "refresh": ["pacman", "-Syu"], - "install": ["pacman", "-Syu", "--noconfirm"] - }, + "query": "pacman", + "install": ["pacman", "-S", "--needed"], "runtime": ["pam", "libseccomp"], - "development": ["clang", "pkg-config", "pandoc", "libseccomp"] + "development": ["gcc", "pkgconf", "pam", "libseccomp", "pandoc-cli"] } } } diff --git a/xtask/src/installer/mod.rs b/xtask/src/installer/mod.rs index 6077d16d..38466b65 100644 --- a/xtask/src/installer/mod.rs +++ b/xtask/src/installer/mod.rs @@ -15,7 +15,7 @@ use semver::Version; use anyhow::anyhow; use capctl::{Cap, CapState}; -use log::{debug, info}; +use log::{debug, info, warn}; use crate::util::path_exe_from_env; use crate::{ @@ -39,7 +39,7 @@ pub struct InstallOptions { #[clap(long, hide = true)] pub nested_install: bool, - /// The OS target for PAM configuration and dependencies installation (if -i is set) + /// The OS target for PAM configuration and the packages to check /// By default, it tries to autodetect it #[clap(long, short)] pub os: Option, @@ -48,8 +48,8 @@ pub struct InstallOptions { #[clap(long, short = 'b')] pub build: bool, - /// Install dependencies before building - #[clap(long, short = 'i')] + /// Deprecated, does nothing: the packages are checked, and are installed by the administrator + #[clap(long, short = 'i', hide = true)] pub install_dependencies: bool, /// Clean the target directory after installing @@ -57,26 +57,6 @@ pub struct InstallOptions { pub clean_after: bool, } -#[derive(Debug, Parser)] -pub struct InstallDependenciesOptions { - /// The OS target for PAM configuration and dependencies installation (if -i is set) - /// By default, it tries to autodetect it - #[clap(long, short)] - pub os: Option, - - /// Install dependencies before installing - #[clap(long, short = 'i')] - pub install_dependencies: bool, - - /// Install development dependencies for compiling - #[clap(long, short = 'd')] - pub dev: bool, - - /// The binary to elevate privileges - #[clap(long, short = 'p', visible_alias = "privbin")] - pub priv_bin: Option, -} - #[derive(Debug, Parser)] pub struct UninstallOptions { /// Delete all configuration files @@ -306,11 +286,6 @@ pub fn configure(os: Option) -> Result<(), anyhow::Error> { configure::configure(os) } -/// In a dry run, the commands of the package manager are printed, not run (`run_checked`). -pub fn dependencies(opts: &InstallDependenciesOptions) -> Result<(), anyhow::Error> { - dependencies::install(opts) -} - /// What the privileged part of `install` would change, for a dry run: these steps change the /// system without a command that a dry run could print. fn install_plan(opts: &InstallOptions, priv_bin: Option<&Path>) -> Vec { @@ -370,17 +345,16 @@ pub fn install(opts: &InstallOptions) -> Result<(), anyhow::Error> { ) }); if opts.install_dependencies { - debug!("Installing dependencies"); - dependencies(&InstallDependenciesOptions { - os: Some(os.clone()), - install_dependencies: true, - dev: opts.build, - priv_bin: priv_bin.clone(), - })?; + warn!( + "-i is deprecated and does nothing: xtask checks the packages, install the missing ones yourself" + ); } if opts.build { debug!("Building sr and chsr"); - build(&opts.build_opts)?; + dependencies::check(&os, true)?; + build::build(&opts.build_opts)?; + } else { + dependencies::check(&os, false)?; } if is_dry_run() { print_plan("install", &install_plan(opts, priv_bin.as_deref())); @@ -405,7 +379,13 @@ pub fn install(opts: &InstallOptions) -> Result<(), anyhow::Error> { } } +/// Build, once the packages to build are checked. An OS that is not detected is only warned +/// about: the build tells then. pub fn build(opts: &BuildOptions) -> Result<(), anyhow::Error> { + match get_os(None) { + Ok(os) => dependencies::check(&os, true)?, + Err(e) => warn!("The packages to build cannot be checked: {e:#}"), + } build::build(opts) } diff --git a/xtask/src/main.rs b/xtask/src/main.rs index af876483..0a920a3f 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -3,7 +3,7 @@ mod doctor; mod installer; pub mod util; -use Command::{Build, Configure, Dependencies, Doctor, Install, Uninstall}; +use Command::{Build, Configure, Doctor, Install, Uninstall}; use std::process::exit; @@ -23,8 +23,6 @@ pub struct Options { #[derive(Debug, Parser)] enum Command { - #[cfg(feature = "cli")] - Dependencies(installer::InstallDependenciesOptions), #[cfg(feature = "cli")] Build(installer::BuildOptions), #[cfg(feature = "cli")] @@ -65,7 +63,6 @@ fn main() { fn run(opts: Options) -> Result<(), anyhow::Error> { util::set_dry_run(opts.dry_run); match opts.command { - Dependencies(opts) => installer::dependencies(&opts), Build(opts) => installer::build(&opts), Install(opts) => installer::install(&opts), Configure { os } => installer::configure(os), @@ -104,14 +101,6 @@ mod tests { for args in [ vec!["xtask", "--dry-run", "install", "-p", program_arg], vec!["xtask", "install", "--dry-run", "-bi", "-p", program_arg], - vec![ - "xtask", - "--dry-run", - "dependencies", - "-i", - "-p", - program_arg, - ], vec!["xtask", "--dry-run", "configure"], ] { run(Options::parse_from(&args)).unwrap(); From e132e045a7e59d8b1d1427d52779b136d2028184 Mon Sep 17 00:00:00 2001 From: LeChatP Date: Sun, 25 Oct 2026 10:53:21 +0100 Subject: [PATCH 46/88] chore: prefer clang over gcc --- .github/workflows/build.yml | 2 +- .github/workflows/install-tests.yml | 6 +++--- tests/vagrant/provision.sh | 4 ++-- xtask/src/installer/dependencies.rs | 4 ++-- xtask/src/installer/deps.json | 8 ++++---- 5 files changed, 12 insertions(+), 12 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c038555f..b6475ee0 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -31,7 +31,7 @@ jobs: - name: Install Dependencies run: | sudo apt-get update - sudo apt-get install -y pkg-config libpam0g-dev libseccomp-dev pandoc + sudo apt-get install -y clang pkg-config libpam0g-dev libseccomp-dev pandoc - name: Install RootAsRole run: cargo xtask install -bp sudo - name: print config diff --git a/.github/workflows/install-tests.yml b/.github/workflows/install-tests.yml index aa24f437..3700f658 100644 --- a/.github/workflows/install-tests.yml +++ b/.github/workflows/install-tests.yml @@ -20,7 +20,7 @@ jobs: - name: Install git and build dependencies run: | apt update -y - apt install -y git curl build-essential pkg-config libpam0g-dev libseccomp-dev pandoc + apt install -y git curl build-essential clang pkg-config libpam0g-dev libseccomp-dev pandoc apt install -y sudo ca-certificates - name: Checkout code @@ -71,7 +71,7 @@ jobs: dnf config-manager --set-enabled crb || true dnf install -y epel-release || true dnf makecache -y - dnf install -y git gcc pkg-config pam-devel libseccomp-devel pandoc + dnf install -y git clang pkg-config pam-devel libseccomp-devel pandoc dnf install -y sudo ca-certificates - name: Checkout code @@ -125,7 +125,7 @@ jobs: - name: Install git and build dependencies run: | zypper refresh - zypper install -y bash rustup git curl gcc pkg-config pam-devel libseccomp-devel pandoc + zypper install -y bash rustup git curl clang pkg-config pam-devel libseccomp-devel pandoc zypper install -y sudo rustup toolchain install stable rustup update diff --git a/tests/vagrant/provision.sh b/tests/vagrant/provision.sh index d6149c69..dcf2272f 100755 --- a/tests/vagrant/provision.sh +++ b/tests/vagrant/provision.sh @@ -9,11 +9,11 @@ case "$ID" in export DEBIAN_FRONTEND=noninteractive apt-get update apt-get install -y build-essential curl git pkg-config e2fsprogs libcap2-bin python3 \ - libpam0g-dev libseccomp-dev pandoc + clang libpam0g-dev libseccomp-dev pandoc ;; arch) pacman -Syu --noconfirm --needed base-devel curl git e2fsprogs libcap python \ - pam libseccomp pandoc-cli + clang pam libseccomp pandoc-cli ;; *) echo "provision.sh: unsupported distribution $ID" >&2 diff --git a/xtask/src/installer/dependencies.rs b/xtask/src/installer/dependencies.rs index 5f659b07..1d2d93f9 100644 --- a/xtask/src/installer/dependencies.rs +++ b/xtask/src/installer/dependencies.rs @@ -256,8 +256,8 @@ mod tests { assert!(!target.development.is_empty(), "{os}"); } assert_eq!( - install_command(&OsTarget::Debian, &["gcc".into(), "pandoc".into()]).unwrap(), - "apt-get install gcc pandoc" + install_command(&OsTarget::Debian, &["clang".into(), "pandoc".into()]).unwrap(), + "apt-get install clang pandoc" ); } diff --git a/xtask/src/installer/deps.json b/xtask/src/installer/deps.json index 214a9888..526c1309 100644 --- a/xtask/src/installer/deps.json +++ b/xtask/src/installer/deps.json @@ -5,28 +5,28 @@ "query": "dpkg", "install": ["apt-get", "install"], "runtime": ["libpam0g", "libseccomp2"], - "development": ["gcc", "pkg-config", "libpam0g-dev", "libseccomp-dev", "pandoc"] + "development": ["clang", "pkg-config", "libpam0g-dev", "libseccomp-dev", "pandoc"] }, "fedora": { "aliases": ["rockylinux", "rocky", "centos", "almalinux", "rhel", "redhat"], "query": "rpm", "install": ["dnf", "install"], "runtime": ["pam", "libseccomp"], - "development": ["gcc", "pkgconfig", "pam-devel", "libseccomp-devel", "pandoc"] + "development": ["clang", "pkgconfig", "pam-devel", "libseccomp-devel", "pandoc"] }, "opensuse": { "aliases": ["suse"], "query": "rpm", "install": ["zypper", "install"], "runtime": ["pam", "libseccomp2"], - "development": ["gcc", "pkg-config", "pam-devel", "libseccomp-devel", "pandoc"] + "development": ["clang", "pkg-config", "pam-devel", "libseccomp-devel", "pandoc"] }, "archlinux": { "aliases": ["arch"], "query": "pacman", "install": ["pacman", "-S", "--needed"], "runtime": ["pam", "libseccomp"], - "development": ["gcc", "pkgconf", "pam", "libseccomp", "pandoc-cli"] + "development": ["clang", "pkgconf", "pam", "libseccomp", "pandoc-cli"] } } } From 4db9aeef8e37de91eaa605537da009a503c7268a Mon Sep 17 00:00:00 2001 From: LeChatP Date: Sun, 25 Oct 2026 11:57:11 +0100 Subject: [PATCH 47/88] fix: chsr keeps a regular expression on the arguments as typed, so that dosr matches it * fix: chsr split the command with shell_words and joined it back, which quoted each word of a regular expression: '/usr/bin/tail ^(-n [0-9]+|-f) /var/log/caddy/[^/ ]+$' became /usr/bin/tail '^(-n' '[0-9]+|-f)' '/var/log/caddy/[^/' ']+$', which dosr never matched (it strips only the outer quotes); every regular expression of several words written with chsr was refused, in 4.1.0 too, and the backslashes were lost * fix: a regular expression on the arguments is now written as typed, after its path; any other command is still normalized as a shell splits it, and del finds a command in the same form * chore: rootasrole-core gets database::command, split_command and regex_arguments, the rule that tells a regular expression, used by dosr to match and by chsr to write * tests: chsr writes the regular expression of the book as typed, whether it comes as one argument or several, with its backslashes, and the rule of core reads it as one; the test fails with the former splitting Co-Authored-By: Claude --- core/src/database/command.rs | 70 ++++++++++++++++++++++++++++++++++++ core/src/database/mod.rs | 1 + src/chsr/cli/data.rs | 3 +- src/chsr/cli/mod.rs | 38 ++++++++++++++++++++ src/chsr/cli/pair.rs | 20 ++++++++++- src/chsr/cli/process.rs | 2 +- src/chsr/cli/process/json.rs | 7 ++-- src/dosr/finder/cmd.rs | 32 ++++------------- 8 files changed, 141 insertions(+), 32 deletions(-) create mode 100644 core/src/database/command.rs diff --git a/core/src/database/command.rs b/core/src/database/command.rs new file mode 100644 index 00000000..77237b2a --- /dev/null +++ b/core/src/database/command.rs @@ -0,0 +1,70 @@ +//! How a command of the policy is written: its path, then its arguments, which may be a +//! regular expression. dosr matches the commands and chsr writes them, both read them here. + +/// The path of a command of the policy and its arguments, both as written. +/// +/// The path keeps its quotes, and a regular expression must not go through shell splitting, +/// which removes its backslashes and splits it on its spaces. +#[must_use] +pub fn split_command(line: &str) -> Option<(&str, &str)> { + let line = line.trim_start(); + let first = line.chars().next()?; + let path_end = if first == '\'' || first == '"' { + line[1..].find(first)? + 2 + } else { + line.find(char::is_whitespace).unwrap_or(line.len()) + }; + Some((&line[..path_end], &line[path_end..])) +} + +/// The regular expression of the arguments of a command of the policy, as written after its +/// path: arguments starting with `^` and ending with `$`, single-quoted or not. +#[must_use] +pub fn regex_arguments(args: &str) -> Option<&str> { + let args = args.trim(); + let args = args + .strip_prefix('\'') + .and_then(|quoted| quoted.strip_suffix('\'')) + .filter(|quoted| quoted.starts_with('^') && quoted.ends_with('$')) + .unwrap_or(args); + (args.len() >= 2 && args.starts_with('^') && args.ends_with('$')).then_some(args) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_split_command() { + assert_eq!( + split_command("/usr/bin/tail ^-n [0-9]+$"), + Some(("/usr/bin/tail", " ^-n [0-9]+$")) + ); + assert_eq!( + split_command(" '/opt/my tool' -v"), + Some(("'/opt/my tool'", " -v")) + ); + assert_eq!( + split_command("\"/opt/my tool\""), + Some(("\"/opt/my tool\"", "")) + ); + assert_eq!(split_command("/usr/bin/id"), Some(("/usr/bin/id", ""))); + assert_eq!(split_command(""), None); + // an unterminated quote is not a path + assert_eq!(split_command("'/usr/bin/id"), None); + } + + #[test] + fn test_regex_arguments() { + assert_eq!( + regex_arguments(" ^(-n [0-9]+|-f) /var/log/caddy/[^/ ]+$"), + Some("^(-n [0-9]+|-f) /var/log/caddy/[^/ ]+$") + ); + assert_eq!(regex_arguments(" '^-f a\\.log$' "), Some("^-f a\\.log$")); + assert_eq!(regex_arguments("^$"), Some("^$")); + assert_eq!(regex_arguments(" --no-pager -u caddy"), None); + assert_eq!(regex_arguments("^"), None); + // what chsr wrote before 4.2.0: every word quoted on its own + assert_eq!(regex_arguments(" '^(-n' '[0-9]+|-f)'"), None); + } +} diff --git a/core/src/database/mod.rs b/core/src/database/mod.rs index 5ed5e45b..9adfadbb 100644 --- a/core/src/database/mod.rs +++ b/core/src/database/mod.rs @@ -12,6 +12,7 @@ use self::options::EnvKey; pub mod score; pub mod actor; +pub mod command; pub mod de; pub mod env; pub mod migration; diff --git a/src/chsr/cli/data.rs b/src/chsr/cli/data.rs index 40c78801..808cbe47 100644 --- a/src/chsr/cli/data.rs +++ b/src/chsr/cli/data.rs @@ -138,7 +138,8 @@ pub struct Inputs { pub task_id: Option, pub task_type: Option, pub cmd_policy: Option, - pub cmd_id: Option>, + /// The command line to write in the policy, see `policy_command_line` + pub cmd_id: Option, pub cred_caps: Option, pub cred_setuid: Option, pub cred_setgid: Option, diff --git a/src/chsr/cli/mod.rs b/src/chsr/cli/mod.rs index fd3e338b..255fbe16 100644 --- a/src/chsr/cli/mod.rs +++ b/src/chsr/cli/mod.rs @@ -1891,6 +1891,44 @@ mod tests { ctx.assert_command_not_contains(&command); } #[test] + fn test_cmd_whitelist_keeps_a_regular_expression_as_typed() { + let (mut ctx, _defer) = + TestContext::new("cmd_whitelist_keeps_a_regular_expression_as_typed"); + let prefix = ["r", "complete", "t", "t_complete", "cmd", "whitelist"]; + let typed = "/usr/bin/tail ^(-n [0-9]+|-f) /var/log/caddy/[^/ ]+$"; + let command = SCommand::Simple(typed.to_string()); + + // quoted as one argument by the shell + ctx.assert_command_vec_success([&prefix[..], &["add", typed]].concat()); + ctx.assert_command_contains(&command); + // dosr reads it as a regular expression, with the same rule of rootasrole-core + let (_, args) = rootasrole_core::database::command::split_command(typed).unwrap(); + assert_eq!( + rootasrole_core::database::command::regex_arguments(args), + Some("^(-n [0-9]+|-f) /var/log/caddy/[^/ ]+$") + ); + + // del finds it, typed as several arguments + ctx.assert_command_vec_success( + [ + &prefix[..], + &[ + "del", + "/usr/bin/tail", + "^(-n [0-9]+|-f)", + "/var/log/caddy/[^/ ]+$", + ], + ] + .concat(), + ); + ctx.assert_command_not_contains(&command); + + // a backslash is kept, where shell splitting removes it + let escaped = r"/usr/bin/tail ^-f /var/log/caddy/access\.log$"; + ctx.assert_command_vec_success([&prefix[..], &["add", escaped]].concat()); + ctx.assert_command_contains(&SCommand::Simple(escaped.to_string())); + } + #[test] fn test_r_complete_t_t_complete_cmd_blacklist_del_super_command_with_spaces() { let (mut ctx, _defer) = TestContext::new("r_complete_t_t_complete_cmd_blacklist_del_super_command_with_spaces"); diff --git a/src/chsr/cli/pair.rs b/src/chsr/cli/pair.rs index 0a1f230c..2737f0fb 100644 --- a/src/chsr/cli/pair.rs +++ b/src/chsr/cli/pair.rs @@ -10,6 +10,7 @@ use crate::cli::data::{Convertion, RoleType, TaskType}; use rootasrole_core::{ database::{ actor::{SActor, SGroupType}, + command::{regex_arguments, split_command}, options::{ EnvBehavior, EnvKey, OptType, PathBehavior, SUMask, TimestampType, WorkdirBehavior, }, @@ -24,6 +25,23 @@ use super::data::{ type MatchingFunction = dyn Fn(&Pair, &mut Inputs) -> Result<(), Box>; +/// The command line to write in the policy for the command that the user typed. +/// +/// A regular expression on the arguments is kept as typed: dosr reads it as written, and shell +/// splitting would quote each of its words, so that it never matches. Any other command is +/// normalized as a shell splits it. +/// +/// # Errors +/// When the command has an unterminated quote. +pub fn policy_command_line(typed: &str) -> Result { + if let Some((path, args)) = split_command(typed) + && let Some(regex) = regex_arguments(args) + { + return Ok(format!("{path} {regex}")); + } + Ok(shell_words::join(shell_words::split(typed)?)) +} + fn recurse_pair_with_action( pair: Pair, inputs: &mut Inputs, @@ -414,7 +432,7 @@ fn match_pair(pair: &Pair, inputs: &mut Inputs) -> Result<(), Box { - inputs.cmd_id = Some(shell_words::split(pair.as_str())?); + inputs.cmd_id = Some(policy_command_line(pair.as_str())?); } // === credentials === Rule::capability => { diff --git a/src/chsr/cli/process.rs b/src/chsr/cli/process.rs index 5110f26a..d6baad8c 100644 --- a/src/chsr/cli/process.rs +++ b/src/chsr/cli/process.rs @@ -255,7 +255,7 @@ pub fn process_input(storage: &mut FileSettings, inputs: Inputs) -> Result>, role_id: &str, task_id: IdTask, - cmd_id: impl IntoIterator, + line: &str, setlist_type: SetListType, action: InputAction, checks: &CommandChecks, ) -> Result> { debug!("chsr role r1 task t1 command whitelist add c1"); let task = rconfig.task(role_id, task_id)?; - let line = shell_words::join(cmd_id); let mut task = task.as_ref().borrow_mut(); let list = match setlist_type { SetListType::White => &mut task.commands.add, SetListType::Black => &mut task.commands.sub, _ => return Err("Unknown list type".into()), }; - let listed = |command: &SCommand| command_line(command) == Some(line.as_str()); + let listed = |command: &SCommand| command_line(command) == Some(line); match action { InputAction::Add => { if list.iter().any(listed) { return Err("Command already exists".into()); } - list.push(command_entry(line.clone(), checks)); + list.push(command_entry(line.to_string(), checks)); } InputAction::Del => { if !list.iter().any(listed) { diff --git a/src/dosr/finder/cmd.rs b/src/dosr/finder/cmd.rs index e1cbdd63..15023b10 100644 --- a/src/dosr/finder/cmd.rs +++ b/src/dosr/finder/cmd.rs @@ -1,6 +1,9 @@ use log::{debug, info, warn}; use rootasrole_core::{ - database::score::{CmdMin, CmdOrder}, + database::{ + command::{regex_arguments, split_command}, + score::{CmdMin, CmdOrder}, + }, util::{all_paths_from_env, match_single_path}, }; use std::path::{Path, PathBuf}; @@ -72,33 +75,12 @@ fn match_path( } } -/// The regular expression of the arguments of a command of the policy, as written after its -/// path: arguments starting with `^` and ending with `$`, single-quoted or not. -fn regex_arguments(args: &str) -> Option<&str> { - let args = args.trim(); - let args = args - .strip_prefix('\'') - .and_then(|quoted| quoted.strip_suffix('\'')) - .filter(|quoted| quoted.starts_with('^') && quoted.ends_with('$')) - .unwrap_or(args); - (args.len() >= 2 && args.starts_with('^') && args.ends_with('$')).then_some(args) -} - /// The path of a command of the policy, unquoted, and its arguments as written: a regular /// expression must not go through shell splitting, which removes its backslashes. fn split_role_command(role_cmd: &str) -> Option<(String, &str)> { - let role_cmd = role_cmd.trim_start(); - let first = role_cmd.chars().next()?; - let path_end = if first == '\'' || first == '"' { - role_cmd[1..].find(first)? + 2 - } else { - role_cmd.find(char::is_whitespace).unwrap_or(role_cmd.len()) - }; - let path = shell_words::split(&role_cmd[..path_end]) - .ok()? - .into_iter() - .next()?; - Some((path, &role_cmd[path_end..])) + let (path, args) = split_command(role_cmd)?; + let path = shell_words::split(path).ok()?.into_iter().next()?; + Some((path, args)) } /// Whether the arguments of the user match the regular expression `regex` of the policy. From 8bb837485cbb49fb9008bf5237ad8f91b6c8c0bf Mon Sep 17 00:00:00 2001 From: LeChatP Date: Sun, 25 Oct 2026 11:58:20 +0100 Subject: [PATCH 48/88] fix: dosr -i shows None for a task without capabilities * fix: "capabilities": "none" or [] gives an empty set, which dosr -i printed as nothing after "With capabilities:"; an empty set now shows None, like a task that does not set them * tests: the display of no capabilities, an empty set, all of them and a list Co-Authored-By: Claude --- src/dosr/lib.rs | 43 +++++++++++++++++++++++++++++++------------ 1 file changed, 31 insertions(+), 12 deletions(-) diff --git a/src/dosr/lib.rs b/src/dosr/lib.rs index ba77860a..e97a0f7e 100644 --- a/src/dosr/lib.rs +++ b/src/dosr/lib.rs @@ -491,7 +491,6 @@ fn main_inner() -> SrResult<()> { } if args.info { - use capctl::CapSet; println_ignore_io_error!( "Role: {}", if execcfg.role.is_empty() { @@ -522,17 +521,7 @@ fn main_inner() -> SrResult<()> { } println_ignore_io_error!( "With capabilities: {}", - execcfg.cred.caps.map_or_else( - || "None".to_string(), - |caps| if caps == !CapSet::empty() { - "All capabilities".to_string() - } else { - caps.into_iter() - .fold(String::new(), |acc, cap| acc + &cap.to_string() + " ") - .trim_end() - .to_string() - } - ) + capabilities_display(execcfg.cred.caps) ); println_ignore_io_error!( "Command: {} {:?}", @@ -764,6 +753,20 @@ fn make_cred() -> std::io::Result { .build()) } +/// The capabilities of the command, as `dosr -i` shows them: a task without capabilities +/// shows `None`, like a task that does not set them. +fn capabilities_display(caps: Option) -> String { + match caps { + Some(caps) if caps == !capctl::CapSet::empty() => "All capabilities".to_string(), + Some(caps) if !caps.is_empty() => caps + .into_iter() + .map(|cap| cap.to_string()) + .collect::>() + .join(" "), + _ => "None".to_string(), + } +} + /// The capabilities of the task that the bounding set does not have anymore: no process of /// this session can obtain them, in a container or under a strict bounding set. fn caps_outside_bounding(caps: Option, bounding: capctl::CapSet) -> capctl::CapSet { @@ -1140,6 +1143,22 @@ mod tests { assert!(reason.contains("setcap"), "{reason}"); } + #[test] + fn test_capabilities_display() { + assert_eq!(capabilities_display(None), "None"); + // "capabilities": "none" or [] in the policy + assert_eq!(capabilities_display(Some(CapSet::empty())), "None"); + assert_eq!( + capabilities_display(Some(!CapSet::empty())), + "All capabilities" + ); + let caps: CapSet = [Cap::NET_RAW, Cap::SYS_BOOT].into_iter().collect(); + assert_eq!( + capabilities_display(Some(caps)), + format!("{} {}", Cap::NET_RAW, Cap::SYS_BOOT) + ); + } + #[test] fn test_caps_outside_bounding() { use capctl::{Cap, CapSet}; From ba91615e352e067cdd5790adc43185ed16d98a9f Mon Sep 17 00:00:00 2001 From: LeChatP Date: Sun, 25 Oct 2026 11:59:22 +0100 Subject: [PATCH 49/88] fix: dosr -- ends the options of dosr, like sudo * fix: every word starting with - was taken as an option of dosr, so dosr -- refused the command line with "Unknown option: --"; scripts write -- before a command that could start with - * feat: -- ends the options: the next word is the command, even when it starts with -, and the rest are its arguments; with nothing after it, there is no command, as without it * tests: -- after options, before a command starting with -, before a plain command, and at the end Co-Authored-By: Claude --- src/dosr/lib.rs | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/src/dosr/lib.rs b/src/dosr/lib.rs index e97a0f7e..4690e665 100644 --- a/src/dosr/lib.rs +++ b/src/dosr/lib.rs @@ -300,6 +300,14 @@ where "-h" | "--help" => { args.help = true; } + // the end of the options, like sudo: the next word is the command, even when it + // starts with - + "--" => { + if let Some(command) = iter.next() { + args.cmd_path = command.as_ref().into(); + } + break; + } "-v" | "--version" => { println_ignore_io_error!("dosr: version {}", env!("CARGO_PKG_VERSION")); std::process::exit(0); @@ -920,6 +928,24 @@ mod tests { assert_eq!(args.cmd_args, ["\"a\"", "'b'", "'\"c\"'"]); } + #[test] + fn test_getopt_double_dash_ends_the_options() { + let args = getopt(["dosr", "-r", "r1", "--", "-weird", "-l", "--"]).unwrap(); + assert_eq!(args.cmd_path, PathBuf::from("-weird")); + assert_eq!(args.cmd_args, vec!["-l".to_string(), "--".to_string()]); + assert_eq!( + args.opt_filter.as_ref().and_then(|f| f.role.as_deref()), + Some("r1") + ); + let args = getopt(["dosr", "--", "ls", "-l"]).unwrap(); + assert_eq!(args.cmd_path, PathBuf::from("ls")); + assert_eq!(args.cmd_args, vec!["-l".to_string()]); + // nothing after it: no command, as without it + let args = getopt(["dosr", "-i", "--"]).unwrap(); + assert!(args.cmd_path.as_os_str().is_empty()); + assert!(args.info); + } + #[test] fn test_getopt_missing_prompt() { assert!(matches!( From e44bdf3ca659a0fe769c0d46c159ddaf69c7a002 Mon Sep 17 00:00:00 2001 From: LeChatP Date: Sun, 25 Oct 2026 12:01:57 +0100 Subject: [PATCH 50/88] fix: dosr logs under the name dosr instead of sr * fix: dosr opened syslog as "sr", the name of RootAsRole 3, so its logs were read with journalctl -t sr; they are now under "dosr", next to those of chsr; a log filter on sr must be changed * fix: the error about the missing capabilities of dosr said "to run sr" * chore: rootasrole-core names its log function subscribe; subsribe stays as a deprecated alias for the users of 4.1 Co-Authored-By: Claude --- core/src/log.rs | 13 +++++++++++-- src/chsr/main.rs | 4 ++-- src/dosr/lib.rs | 7 +++---- 3 files changed, 16 insertions(+), 8 deletions(-) diff --git a/core/src/log.rs b/core/src/log.rs index eb4759f5..01e50bea 100644 --- a/core/src/log.rs +++ b/core/src/log.rs @@ -2,7 +2,7 @@ #[allow(clippy::unnecessary_wraps)] // Same signature as non debug. /// # Errors /// Returns an error if the logger fails to initialize -pub fn subsribe(_: &str) -> std::io::Result<()> { +pub fn subscribe(_: &str) -> std::io::Result<()> { env_logger::Builder::from_default_env() .filter_level(log::LevelFilter::Trace) .format_module_path(true) @@ -11,7 +11,7 @@ pub fn subsribe(_: &str) -> std::io::Result<()> { } #[cfg(not(debug_assertions))] -pub fn subsribe(tool: &str) -> std::io::Result<()> { +pub fn subscribe(tool: &str) -> std::io::Result<()> { use log::LevelFilter; use std::ffi::CString; @@ -79,3 +79,12 @@ impl log::Log for CustomSysLogger { fn flush(&self) {} } + +/// The former, misspelled name of [`subscribe`], kept for the users of rootasrole-core 4.1. +/// +/// # Errors +/// As [`subscribe`]. +#[deprecated(since = "4.2.0", note = "use subscribe")] +pub fn subsribe(tool: &str) -> std::io::Result<()> { + subscribe(tool) +} diff --git a/src/chsr/main.rs b/src/chsr/main.rs index 28207b6e..85c8c8a5 100644 --- a/src/chsr/main.rs +++ b/src/chsr/main.rs @@ -57,13 +57,13 @@ fn main() -> Result> { use log::{debug, error, warn}; use rootasrole_core::{ file::FileSettings, - log::subsribe, + log::subscribe, util::{RAR_CFG_TYPE, definitive_drop}, }; use crate::security::full_program_lock; - subsribe("chsr")?; + subscribe("chsr")?; // Drop privileges we don't need definitive_drop(&[ Cap::DAC_OVERRIDE, diff --git a/src/dosr/lib.rs b/src/dosr/lib.rs index 4690e665..f70fe886 100644 --- a/src/dosr/lib.rs +++ b/src/dosr/lib.rs @@ -57,7 +57,7 @@ use rootasrole_core::{ actor::{SGroupType, SGroups, SUserType}, options::EnvBehavior, }, - log::subsribe, + log::subscribe, }; use log::{debug, error, info}; @@ -173,8 +173,7 @@ impl Default for Cli { } } -const CAPABILITIES_ERROR: &str = - "You need at least dac_read_search or dac_override, setpcap and setuid capabilities to run sr"; +const CAPABILITIES_ERROR: &str = "You need at least dac_read_search or dac_override, setpcap and setuid capabilities to run dosr"; fn cap_effective_error(caplist: &str) -> String { format!("Unable to toggle {caplist} privilege. {CAPABILITIES_ERROR}") } @@ -349,7 +348,7 @@ where #[cfg(not(tarpaulin_include))] pub fn dosr_main() { - if let Err(e) = subsribe("sr") { + if let Err(e) = subscribe("dosr") { eprintln_ignore_io_error!("dosr: Failed to initialize logging: {e}"); std::process::exit(1); } From 4267132a071785b1101d5d3d85e0cf05a5e1b76f Mon Sep 17 00:00:00 2001 From: LeChatP Date: Sun, 25 Oct 2026 12:03:18 +0100 Subject: [PATCH 51/88] fix: the help of dosr and chsr lists what they accept * fix: dosr --help had neither -S nor -K, and its usage now shows that -- may end the options * fix: chsr's help offered inherit for root, bounding and authentication, and display for execinfo, which the grammar refuses: execinfo takes show; the main commands now list options, file, convert, upgrade and editor * fix: two typos in the messages of chsr editor ("confguration", "[Y]ontinue") Co-Authored-By: Claude --- src/chsr/cli/editor.rs | 4 ++-- src/chsr/cli/usage.rs | 35 ++++++++++++++++++++++++++++++----- src/dosr/lib.rs | 8 +++++++- 3 files changed, 39 insertions(+), 8 deletions(-) diff --git a/src/chsr/cli/editor.rs b/src/chsr/cli/editor.rs index 5c1bf376..6adcb767 100644 --- a/src/chsr/cli/editor.rs +++ b/src/chsr/cli/editor.rs @@ -176,7 +176,7 @@ where new_config.warn_anomalies(&mut warn_handler); debug!("config: {new_config:#?}"); let after = serde_json::to_string_pretty(&new_config)?; - writeln!(output, "Resulting confguration: {after}")?; + writeln!(output, "Resulting configuration: {after}")?; let after = serde_json::from_str::(&after)?; debug!("re-serialised: {after:#?}"); // Yes == save, No and edit again == continue loop, abort == return false @@ -207,7 +207,7 @@ where Err(e) => { writeln!(output, "Your modifications are invalid:\n{e}")?; writeln!(output, "Do you want to continue editing?")?; - writeln!(output, " [Y]ontinue editing (Recommended)")?; + writeln!(output, " [Y]es to continue editing (Recommended)")?; writeln!(output, " [A]bort to exit without saving")?; write!(output, "Your choice [Y/a]: ")?; output.flush()?; diff --git a/src/chsr/cli/usage.rs b/src/chsr/cli/usage.rs index 8c48b498..44fa26b6 100644 --- a/src/chsr/cli/usage.rs +++ b/src/chsr/cli/usage.rs @@ -78,8 +78,33 @@ const USAGE_COMMANDS: UsageSection = UsageSection { description: "Manage roles and related operations.", indent: 1, }, + UsageItem { + name: "options, o", + description: "Manage the options of the whole policy.", + indent: 1, + }, + UsageItem { + name: "file, f [path|list]", + description: "Edit one file of a policy directory, by its path or its name, or list them.", + indent: 1, + }, + UsageItem { + name: "convert, c", + description: "Convert the policy between JSON and CBOR.", + indent: 1, + }, + UsageItem { + name: "upgrade", + description: "Save the policy files written by an older version in the current format.", + indent: 1, + }, + UsageItem { + name: "editor, e [path]", + description: "Edit a policy file in a text editor, and save it once it is valid.", + indent: 1, + }, ], - examples: &["chsr --help", "chsr list"], + examples: &["chsr --help", "chsr list", "chsr file list", "chsr editor"], }; const USAGE_ROLE: UsageSection = UsageSection { @@ -289,12 +314,12 @@ const USAGE_OPTIONS_GENERAL: UsageSection = UsageSection { }, UsageItem { name: "root [policy]", - description: "Defines when root gets privileges (unset, privileged, user, inherit).", + description: "Defines when root gets privileges (unset, privileged, user).", indent: 1, }, UsageItem { name: "bounding [policy]", - description: "Defines how dropped capabilities are handled (unset, strict, ignore, inherit).", + description: "Defines how dropped capabilities are handled (unset, strict, ignore).", indent: 1, }, UsageItem { @@ -309,12 +334,12 @@ const USAGE_OPTIONS_GENERAL: UsageSection = UsageSection { }, UsageItem { name: "authentication [policy]", - description: "Defines if user needs to authenticate (unset, skip, perform, inherit).", + description: "Defines if user needs to authenticate (unset, skip, perform).", indent: 1, }, UsageItem { name: "execinfo [policy]", - description: "Defines if user can see execution settings (unset, display, hide, inherit).", + description: "Defines if user can see execution settings (unset, show, hide).", indent: 1, }, UsageItem { diff --git a/src/dosr/lib.rs b/src/dosr/lib.rs index f70fe886..7bc86047 100644 --- a/src/dosr/lib.rs +++ b/src/dosr/lib.rs @@ -93,7 +93,7 @@ pub(crate) const RAR_CFG_DATA_PATH: &str = "target/rootasrole.json"; //and each role has a set of rights to execute commands."; #[allow(clippy::needless_raw_string_hashes)] const USAGE: &str = formatcp!( - r#"{UNDERLINE}{BOLD}Usage:{RST} {BOLD}dosr{RST} [OPTIONS] [COMMAND]... + r#"{UNDERLINE}{BOLD}Usage:{RST} {BOLD}dosr{RST} [OPTIONS] [--] [COMMAND]... {UNDERLINE}{BOLD}Arguments:{RST} [COMMAND]... @@ -116,6 +116,12 @@ const USAGE: &str = formatcp!( Prompt option allows you to override the default password prompt and use a custom one [default: "Password: "] + {BOLD}-S, --stdin{RST} + Read the password from the standard input instead of the terminal + + {BOLD}-K, --remove-timestamp{RST} + Remove your timestamp cookies before authentication; without a command, only remove them + {BOLD}-u, --user {RST} Specify the user to execute the command as From e6b83c6acfa3d25533ec0ef35e3fd01e5f706724 Mon Sep 17 00:00:00 2001 From: LeChatP Date: Sun, 25 Oct 2026 12:05:40 +0100 Subject: [PATCH 52/88] fix: chsr editor edits the file of the policy that the other commands edit * fix: without a path, chsr editor opened the root file, while chsr role and the other commands edit default.json of a policy directory: with the layout of the packages, the roles were not in the file it opened; it now opens the same file, the one named, default.json, the only file, or the policy of the root file * fix: chsr editor gave the path of the policy file where the temporary folder was expected, so the copy for the editor could not be created; the copy is always made in the temporary folder that Landlock allows * fix: a path that is not a file of the policy was read, edited and never saved; it is now refused like with chsr file; the path of the root file opens the root file * chore: --type of chsr editor is ignored with a warning, a file of the policy keeps its format; rootasrole-core gets FileSettings::root_path * tests: the file opened without a path, by name, by path, the root file, and the refusal of a file outside the policy Co-Authored-By: Claude --- core/src/file.rs | 6 ++++ src/chsr/cli/mod.rs | 64 +++++++++++++++++++++++++++++++++-------- src/chsr/cli/process.rs | 26 +++++++++++++++++ 3 files changed, 84 insertions(+), 12 deletions(-) diff --git a/core/src/file.rs b/core/src/file.rs index b1161ecb..6ba2d287 100644 --- a/core/src/file.rs +++ b/core/src/file.rs @@ -589,6 +589,12 @@ impl FileSettings { self.map.iter().map(|config| &config.data.data).collect() } + /// The path of the root file. + #[must_use] + pub fn root_path(&self) -> &Path { + &self.root.path + } + /// The policy files apart from the root file (those of a policy directory, or the one file /// that the root file names), with their path. pub fn policy_files(&self) -> impl Iterator>)> { diff --git a/src/chsr/cli/mod.rs b/src/chsr/cli/mod.rs index 255fbe16..a1278d8c 100644 --- a/src/chsr/cli/mod.rs +++ b/src/chsr/cli/mod.rs @@ -14,10 +14,10 @@ use bon::builder; use data::{Cli, InputAction, Inputs, Rule}; use landlock::RulesetStatus; -use log::debug; +use log::{debug, warn}; use pair::recurse_pair; use pest::Parser; -use process::process_input; +use process::{EditorTarget, editor_target, process_input}; use rootasrole_core::file::FileSettings; use usage::print_usage; @@ -75,17 +75,15 @@ where if ruleset == &RulesetStatus::NotEnforced { return Err("Editor mode requires landlock to be enforced.".into()); } - if let Some(path) = inputs.editor_path { - if let Some(storage) = storage.get(&path) { - return start_editing(&path, &mut *storage.as_ref().borrow_mut()); - } - let file = FileSettings::read_policy(&path, inputs.editor_type.unwrap_or_default())?; - return start_editing(&path, &mut *file.data.data.as_ref().borrow_mut()); + // the copy that the editor opens is always in the temporary folder, which Landlock allows + let folder = folder.ok_or("the editor needs a temporary folder")?; + if inputs.editor_type.is_some() { + warn!("--type is ignored: chsr editor edits a file of the policy, in its format"); } - return start_editing( - folder.ok_or("the editor needs a temporary folder")?, - &mut *storage.get_root_mut(), - ); + return match editor_target(storage, inputs.editor_path.as_deref())? { + EditorTarget::Root => start_editing(folder, &mut *storage.get_root_mut()), + EditorTarget::Policy(policy) => start_editing(folder, &mut *policy.borrow_mut()), + }; } process_input(storage, inputs) } @@ -1537,6 +1535,48 @@ mod tests { assert_eq!(fs::read_to_string(&default).unwrap(), default_before); } + #[test] + fn test_editor_edits_the_file_of_the_other_commands() { + let (root, dir, _cleanup) = policy_directory( + "editor_edits_the_file_of_the_other_commands", + &[ + ("default.json", serde_json::json!({"roles": []})), + ("extra.json", serde_json::json!({"roles": []})), + ], + ); + let settings = + FileSettings::write_all(root.clone(), root.clone(), StorageMethod::JSON).unwrap(); + let file = |name: &str| { + Rc::clone( + settings + .get(std::path::Path::new(&format!("{dir}/{name}"))) + .unwrap(), + ) + }; + let edits = |path: Option<&str>, expected: &Rc>| match editor_target( + &settings, + path.map(std::path::Path::new), + ) + .unwrap() + { + EditorTarget::Policy(policy) => assert!(Rc::ptr_eq(&policy, expected), "{path:?}"), + EditorTarget::Root => panic!("{path:?} opened the root file"), + }; + // without a path, default.json of the directory, like chsr r newrole create + edits(None, &file("default.json")); + // another file, by its name or by its path + edits(Some("extra.json"), &file("extra.json")); + edits(Some(&format!("{dir}/extra.json")), &file("extra.json")); + // the root file, by its path + assert!(matches!( + editor_target(&settings, Some(std::path::Path::new(&root))).unwrap(), + EditorTarget::Root + )); + // a file that is not one of the policy is refused, instead of edited and never saved + assert!(editor_target(&settings, Some(std::path::Path::new("nope.json"))).is_err()); + assert!(editor_target(&settings, Some(std::path::Path::new("/etc/passwd"))).is_err()); + } + #[test] fn test_policy_directory_without_default_file() { let (root, dir, _cleanup) = policy_directory( diff --git a/src/chsr/cli/process.rs b/src/chsr/cli/process.rs index d6baad8c..a7abc101 100644 --- a/src/chsr/cli/process.rs +++ b/src/chsr/cli/process.rs @@ -634,6 +634,32 @@ fn target_policy( } } +/// What `chsr editor` edits. +pub enum EditorTarget { + /// The root file, which tells where the policy is stored + Root, + /// A file of the policy + Policy(Rc>), +} + +/// What `chsr editor` edits: the root file when its path is given, else the policy file that the +/// other commands edit, see `target_policy`. +/// +/// # Errors +/// When the path is not a file of the policy. +pub fn editor_target( + storage: &FileSettings, + path: Option<&Path>, +) -> Result> { + if path.is_some_and(|path| path == storage.root_path()) { + return Ok(EditorTarget::Root); + } + let path = path + .map(|path| path.to_str().ok_or("The path is not valid UTF-8")) + .transpose()?; + target_policy(storage, path).map(EditorTarget::Policy) +} + pub fn perform_on_target_opt( rconfig: &Rc>, role_id: Option<&String>, From 9762f7e73f614e80e86fc781561088e470d936a7 Mon Sep 17 00:00:00 2001 From: LeChatP Date: Sun, 25 Oct 2026 12:17:32 +0100 Subject: [PATCH 53/88] fix: chsr is confined by Landlock even when an editor or one of its files is missing * fix: chsr opened every editor of RAR_CHSR_EDITOR_PATH, /usr/share/vim, /etc/vimrc and the other paths of the editor with PathFd::new()?, so one missing path made the whole ruleset fail, and chsr ran without Landlock with only a warning in syslog; Debian has no /etc/vimrc, so chsr was never confined there, and a system without vim neither * fix: a missing optional path is now left out, which only allows less; the root file, the policy directory and the temporary folder stay required * tests: a missing path gives no rule, an existing one does; in the Debian VM without vim, chsr convert --from /etc/hostname is refused with Permission denied, where chsr read it before Co-Authored-By: Claude --- src/chsr/security.rs | 80 +++++++++++++++++++++++++------------------- 1 file changed, 45 insertions(+), 35 deletions(-) diff --git a/src/chsr/security.rs b/src/chsr/security.rs index fee00e24..36fc6d52 100644 --- a/src/chsr/security.rs +++ b/src/chsr/security.rs @@ -3,10 +3,11 @@ use std::path::PathBuf; /// Security module, provides Landlock and Seccomp locking /// This way, the administrator cannot edit files that should not be edited use landlock::{ - ABI, Access, AccessFs, Compatible, PathBeneath, PathFd, RestrictionStatus, Ruleset, + ABI, Access, AccessFs, BitFlags, Compatible, PathBeneath, PathFd, RestrictionStatus, Ruleset, RulesetAttr, RulesetCreatedAttr, }; use libseccomp::{ScmpAction, ScmpFilterContext, ScmpSyscall}; +use log::debug; use crate::{cli::editor::SYSTEM_EDITOR_LIST, util::RAR_CFG_PATH}; @@ -34,49 +35,46 @@ pub fn full_program_lock( AccessFs::from_all(ABI::V6), ))?; - //TODO: Add rule allowing the path of the policy - for &editor in SYSTEM_EDITOR_LIST { - if !editor.is_empty() { - ruleset = ruleset.add_rule(PathBeneath::new( - PathFd::new(editor)?, - AccessFs::from_read(ABI::V6), - ))?; + // what the editor needs to run, where it exists + let read = AccessFs::from_read(ABI::V6); + let data = read & !AccessFs::Execute; + let optional = SYSTEM_EDITOR_LIST + .iter() + .filter(|editor| !editor.is_empty()) + .map(|&editor| (editor, read)) + .chain([ + ("/usr/share/locale", data), + ("/usr/share/terminfo", data), + ("/usr/share/vim", data), + ("/etc/vimrc", data), + ("/lib", read), + ("/usr/lib", read), + ]); + for (path, access) in optional { + if let Some(rule) = optional_rule(path, access) { + ruleset = ruleset.add_rule(rule)?; } } Ok(ruleset - // Allow locale + terminfo - .add_rule(PathBeneath::new( - PathFd::new("/usr/share/locale")?, - AccessFs::from_read(ABI::V6) & !AccessFs::Execute, - ))? - .add_rule(PathBeneath::new( - PathFd::new("/usr/share/terminfo")?, - AccessFs::from_read(ABI::V6) & !AccessFs::Execute, - ))? - // Allow vim runtime files - .add_rule(PathBeneath::new( - PathFd::new("/usr/share/vim")?, - AccessFs::from_read(ABI::V6) & !AccessFs::Execute, - ))? - // Allow /etc/vimrc - .add_rule(PathBeneath::new( - PathFd::new("/etc/vimrc")?, - AccessFs::from_read(ABI::V6) & !AccessFs::Execute, - ))? - .add_rule(PathBeneath::new( - PathFd::new("/lib")?, - AccessFs::from_read(ABI::V6), - ))? - .add_rule(PathBeneath::new( - PathFd::new("/usr/lib")?, - AccessFs::from_read(ABI::V6), - ))? .set_compatibility(landlock::CompatLevel::BestEffort) .no_new_privs(true) .restrict_self()?) } +/// The rule for a path that the editor may need, or `None` when it does not exist on this +/// system: one missing path, like `/etc/vimrc` on Debian or an editor that is not installed, made +/// the whole ruleset fail, and chsr then ran without Landlock. Leaving it out only allows less. +fn optional_rule(path: &str, access: BitFlags) -> Option> { + match PathFd::new(path) { + Ok(fd) => Some(PathBeneath::new(fd, access)), + Err(e) => { + debug!("Landlock: {path} is left out: {e}"); + None + } + } +} + #[cfg(debug_assertions)] const SECCOMP: ScmpAction = ScmpAction::Log; #[cfg(not(debug_assertions))] @@ -146,3 +144,15 @@ pub fn seccomp_lock() -> std::io::Result<()> { Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_a_missing_path_is_left_out() { + let read = AccessFs::from_read(ABI::V6); + assert!(optional_rule("/nonexistent/rar-editor", read).is_none()); + assert!(optional_rule("/", read).is_some()); + } +} From 81d86635d3e30b1dbc0702c06dc0a98590c22bae Mon Sep 17 00:00:00 2001 From: LeChatP Date: Sun, 25 Oct 2026 12:17:33 +0100 Subject: [PATCH 54/88] fix: chsr file says why a file of the policy directory cannot be loaded * fix: a file of the directory that chsr could not load (written by hand without its version, for example) was answered "is not a file of the policy", like a file that does not exist; chsr file and chsr editor now give the path and the reason * tests: a file without version, named by its name and by its path, and a file that does not exist Co-Authored-By: Claude --- src/chsr/cli/mod.rs | 23 +++++++++++++++++++++++ src/chsr/cli/process.rs | 19 ++++++++++++------- 2 files changed, 35 insertions(+), 7 deletions(-) diff --git a/src/chsr/cli/mod.rs b/src/chsr/cli/mod.rs index a1278d8c..22fec4b6 100644 --- a/src/chsr/cli/mod.rs +++ b/src/chsr/cli/mod.rs @@ -1535,6 +1535,29 @@ mod tests { assert_eq!(fs::read_to_string(&default).unwrap(), default_before); } + #[test] + fn test_a_file_that_cannot_be_loaded_says_why() { + let (root, dir, _cleanup) = policy_directory( + "a_file_that_cannot_be_loaded_says_why", + &[("default.json", serde_json::json!({"roles": []}))], + ); + // written by hand without its version: dosr reads it, chsr does not edit it + fs::write(format!("{dir}/broken.json"), r#"{"roles": []}"#).unwrap(); + for name in ["broken.json".to_string(), format!("{dir}/broken.json")] { + let error = chsr_on(&root, &format!("file {name} r y create")) + .unwrap_err() + .to_string(); + assert!( + error.contains("cannot be loaded") && error.contains("version"), + "{error}" + ); + } + let error = chsr_on(&root, "file nope.json r y create") + .unwrap_err() + .to_string(); + assert!(error.contains("is not a file of the policy"), "{error}"); + } + #[test] fn test_editor_edits_the_file_of_the_other_commands() { let (root, dir, _cleanup) = policy_directory( diff --git a/src/chsr/cli/process.rs b/src/chsr/cli/process.rs index a7abc101..d80241bd 100644 --- a/src/chsr/cli/process.rs +++ b/src/chsr/cli/process.rs @@ -609,13 +609,18 @@ fn target_policy( if let Some(policy) = storage.get(wanted) { return Ok(policy.clone()); } - return files - .iter() - .find(|(path, _)| { - wanted.components().count() == 1 && path.file_name() == Some(wanted.as_os_str()) - }) - .map(|(_, policy)| Rc::clone(policy)) - .ok_or_else(|| format!("{name} is not a file of the policy: {}", names()).into()); + let named = |path: &Path| { + path == wanted + || wanted.components().count() == 1 && path.file_name() == Some(wanted.as_os_str()) + }; + if let Some(policy) = files.iter().find(|(path, _)| named(path)) { + return Ok(Rc::clone(policy.1)); + } + // a file of the directory that could not be loaded: say why, it is not unknown + if let Some((path, error)) = storage.get_ignored().iter().find(|(path, _)| named(path)) { + return Err(format!("{} cannot be loaded: {error}", path.display()).into()); + } + return Err(format!("{name} is not a file of the policy: {}", names()).into()); } match files.as_slice() { [] => Ok(storage.get_root().config.clone()), From e5df87888bbcd9019ef974799f1edb286cf13b0b Mon Sep 17 00:00:00 2001 From: LeChatP Date: Sun, 25 Oct 2026 12:17:33 +0100 Subject: [PATCH 55/88] fix: chsr prints the error of a failed command on its standard error * fix: when a command failed, chsr only logged the error, which goes to syslog alone in a release build: the terminal showed nothing, and the exit status was the only sign; it now prints chsr: cannot edit the policy: , like its other errors Co-Authored-By: Claude --- src/chsr/main.rs | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/chsr/main.rs b/src/chsr/main.rs index 85c8c8a5..34c3f716 100644 --- a/src/chsr/main.rs +++ b/src/chsr/main.rs @@ -54,7 +54,7 @@ fn main() -> Result> { }; use ::landlock::{RestrictionStatus, RulesetStatus}; use capctl::Cap; - use log::{debug, error, warn}; + use log::{debug, warn}; use rootasrole_core::{ file::FileSettings, log::subscribe, @@ -136,9 +136,7 @@ fn main() -> Result> { Ok(ExitCode::SUCCESS) } Ok(false) => Ok(ExitCode::SUCCESS), - Err(e) => { - error!("Unable to edit policy : {e}"); - Ok(ExitCode::FAILURE) - } + // on the terminal too: in a release build, the log only goes to syslog + Err(e) => Ok(failure(&format!("cannot edit the policy: {e}"))), } } From 73412735da667f74d1cb1c6ab1ed92271a9e3818 Mon Sep 17 00:00:00 2001 From: LeChatP Date: Sun, 25 Oct 2026 12:17:33 +0100 Subject: [PATCH 56/88] tests: end-to-end checks of the fixes found while writing the dosr and chsr chapters * tests: a regular expression of several words written with chsr is matched by dosr, the executions are logged under dosr, chsr is confined by Landlock without vim, -- ends the options, dosr -i shows None for a task without capabilities * tests: the policy file of e2e.sh has the version of the root file, without which chsr does not load it Co-Authored-By: Claude --- tests/vagrant/e2e.sh | 67 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/tests/vagrant/e2e.sh b/tests/vagrant/e2e.sh index 2faba8f6..9b05cff8 100755 --- a/tests/vagrant/e2e.sh +++ b/tests/vagrant/e2e.sh @@ -71,9 +71,13 @@ setup() { printf '%s\n' "[Unit]" "Description=RootAsRole end-to-end test unit" \ "[Service]" "ExecStart=/bin/sleep infinity" >"/etc/systemd/system/$UNIT.service" systemctl daemon-reload + # the version of the installed policy, without which chsr does not edit the file + local version + version=$(sed -n 's/^ *"version": *"\([^"]*\)".*/\1/p' "$ROOT_FILE" | head -n 1) write_policy "$E2E_POLICY" "$( cat </dev/null 2>&1; then + result=0 + fi + chsr file e2e.json r e2e t chsr-regex del || return 1 + return "$result" +} + +# the executions are logged under the name dosr, no longer sr +dosr_logs_under_its_name() { + local since + since=$(date '+%Y-%m-%d %H:%M:%S') + sleep 1 + as_user dosr /usr/bin/date >/dev/null || return 1 + for _ in 1 2 3 4 5; do + if journalctl -t dosr --since "$since" -o cat | grep -q 'TASK=date ;.*COMMAND=/usr/bin/date'; then + ! journalctl -t sr --since "$since" -o cat | grep -q 'COMMAND=/usr/bin/date' + return + fi + sleep 1 + done + return 1 +} + +# chsr is confined by Landlock, even without vim: one missing optional path made the whole ruleset +# fail; root itself cannot make chsr read a file outside the policy +chsr_is_confined_by_landlock() { + local since out + since=$(date '+%Y-%m-%d %H:%M:%S') + sleep 1 + out=$(chsr convert --from json /etc/hostname json "$POLICY_DIR/rar-e2e-convert.json" 2>&1) + remove_policy "$POLICY_DIR/rar-e2e-convert.json" + if journalctl -t chsr --since "$since" -o cat | grep -q 'Failed to apply landlock' || + ! grep -qi 'permission denied' <<<"$out"; then + echo "$out" >&2 + return 1 + fi +} + +# -- ends the options of dosr +double_dash_ends_the_options() { + as_user dosr -r e2e -t date -- /usr/bin/date >/dev/null +} + +# dosr -i shows None for a task whose capabilities are an empty set +info_shows_no_capabilities_as_none() { + as_user dosr -i -r e2e -t service /usr/bin/systemctl start "$UNIT" | + grep -qx 'With capabilities: None' +} + trap cleanup EXIT setup check "the installed binaries belong to root, dosr has its capabilities" installed_binaries_belong_to_root @@ -350,4 +412,9 @@ check "the variables of pam_env go through the env filter of the task" pam_env_i check "a wrong password is asked again, up to the tries of the policy" password_is_asked_again check "a failed lookup of an actor disables the roles with ssd" ssd_role_disabled_by_a_failed_lookup check "chsr edits default.json of the policy directory" chsr_edits_the_default_file +check "a regular expression written with chsr is matched by dosr" chsr_regex_is_matched_by_dosr +check "the executions are logged under the name dosr" dosr_logs_under_its_name +check "chsr is confined by Landlock" chsr_is_confined_by_landlock +check "-- ends the options of dosr" double_dash_ends_the_options +check "dosr -i shows None for a task without capabilities" info_shows_no_capabilities_as_none exit "$FAILED" From 565f54cb6755a0cf7e8bd828945b6f8063d02968 Mon Sep 17 00:00:00 2001 From: LeChatP Date: Sun, 25 Oct 2026 15:30:27 +0100 Subject: [PATCH 57/88] feat: xtask builds with clang or gcc, and without pandoc * feat: a requirement of deps.json can be several packages of which one is enough: the C compiler that links the binaries is clang or gcc; a missing group is named whole, and its first package is the one to install * feat: optional packages, found by their command in PATH whatever installed them: pandoc only builds the manual pages, so without it xtask warns, with the command that installs it, and builds and installs everything else; doctor lists it * fix: the manual pages are built only with pandoc and gzip, the pages of a former build are removed first so they are never installed instead, and the installation says that none was built instead of failing to copy them * chore: command_exists moves from doctor to util * tests: one package of a group is enough, a failed query is not a missing package, the message of a group and of an optional package, every target accepts clang or gcc and has pandoc as optional; in the Debian VM without pandoc and without the clang package, cargo xtask install -bp sudo builds with gcc, installs without manual pages and exits 0 Co-Authored-By: Claude --- xtask/src/doctor.rs | 51 ++----- xtask/src/installer/build.rs | 14 +- xtask/src/installer/dependencies.rs | 224 ++++++++++++++++++++++++---- xtask/src/installer/deps.json | 12 +- xtask/src/installer/install.rs | 5 + xtask/src/util.rs | 35 +++++ 6 files changed, 270 insertions(+), 71 deletions(-) diff --git a/xtask/src/doctor.rs b/xtask/src/doctor.rs index 339b5b69..8c82c09a 100644 --- a/xtask/src/doctor.rs +++ b/xtask/src/doctor.rs @@ -1,14 +1,11 @@ -use std::{ - env, fs, - path::{Path, PathBuf}, -}; +use std::fs; use clap::Parser; use log::{info, warn}; use crate::{ installer::dependencies, - util::{RAR_CFG_PATH, detect_priv_bin, get_os}, + util::{RAR_CFG_PATH, command_exists, detect_priv_bin, get_os}, }; #[derive(Debug, Parser)] @@ -18,39 +15,6 @@ pub struct DoctorOptions { pub full: bool, } -fn command_exists(command: &str) -> bool { - let Some(path) = env::var_os("PATH") else { - return false; - }; - - for dir in env::split_paths(&path) { - let candidate: PathBuf = dir.join(command); - if is_executable(&candidate) { - return true; - } - } - - false -} - -fn is_executable(path: &Path) -> bool { - if let Ok(metadata) = fs::metadata(path) { - if !metadata.is_file() { - return false; - } - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - return metadata.permissions().mode() & 0o111 != 0; - } - #[cfg(not(unix))] - { - return true; - } - } - false -} - pub fn doctor(opts: &DoctorOptions) -> Result<(), anyhow::Error> { let os = get_os(None)?; info!("Detected target OS: {os}"); @@ -76,6 +40,17 @@ pub fn doctor(opts: &DoctorOptions) -> Result<(), anyhow::Error> { Err(e) => warn!("The packages to {what} RootAsRole cannot be checked: {e:#}"), } } + match dependencies::missing_optional(&os) { + Ok(missing) => { + for (package, purpose) in missing { + warn!( + "{}", + dependencies::optional_message(&os, &package, &purpose)? + ); + } + } + Err(e) => warn!("The optional packages cannot be checked: {e:#}"), + } if opts.full { for command in ["gzip", "upx"] { diff --git a/xtask/src/installer/build.rs b/xtask/src/installer/build.rs index 53cb3ccd..632fd7c7 100644 --- a/xtask/src/installer/build.rs +++ b/xtask/src/installer/build.rs @@ -1,10 +1,10 @@ use std::{fs, process::Command}; -use log::debug; +use log::{debug, warn}; use crate::{ installer::Toolchain, - util::{change_dir_to_project_root, run_checked}, + util::{change_dir_to_project_root, command_exists, run_checked}, }; use super::BuildOptions; @@ -45,9 +45,19 @@ pub fn build(options: &BuildOptions) -> Result<(), anyhow::Error> { Ok(()) } +/// The manual pages need pandoc and gzip: without them, they are left out, and the installation +/// installs none. fn build_manpages() -> Result<(), anyhow::Error> { debug!("Building manpages"); + // the pages of a former build are never installed instead of these ones let _ = fs::remove_dir_all("target/man/"); + if let Some(command) = ["pandoc", "gzip"] + .into_iter() + .find(|command| !command_exists(command)) + { + warn!("{command} is not installed: the manual pages are not built"); + return Ok(()); + } fs::create_dir_all("target/man/")?; run_checked( Command::new("pandoc").args([ diff --git a/xtask/src/installer/dependencies.rs b/xtask/src/installer/dependencies.rs index 1d2d93f9..a5c8f2bc 100644 --- a/xtask/src/installer/dependencies.rs +++ b/xtask/src/installer/dependencies.rs @@ -2,7 +2,7 @@ //! command that installs the missing ones, it does not install them: like Rust, they are left //! to the administrator. -use std::{borrow::Cow, collections::HashMap, io, process::Command, sync::OnceLock}; +use std::{borrow::Cow, collections::HashMap, fmt, io, process::Command, sync::OnceLock}; use anyhow::{Context, anyhow}; use log::warn; @@ -23,8 +23,78 @@ struct TargetDependencies<'a> { query: PackageQuery, /// The command that installs packages, printed for the administrator, never run install: Vec>, - runtime: Vec>, - development: Vec>, + runtime: Vec>, + development: Vec>, + /// Packages that only some steps of the build need: without them, these steps are left out + #[serde(default)] + optional: Vec>, +} + +/// A package that is needed, or several packages of which one is enough, like a C compiler. +#[derive(Debug, Deserialize)] +#[serde(untagged)] +enum Requirement<'a> { + Package(Cow<'a, str>), + AnyOf(Vec>), +} + +impl Requirement<'_> { + fn packages(&self) -> Vec<&str> { + match self { + Self::Package(package) => vec![package.as_ref()], + Self::AnyOf(packages) => packages.iter().map(AsRef::as_ref).collect(), + } + } +} + +/// A package that a step of the build needs, which is left out without it. +#[derive(Debug, Deserialize)] +struct OptionalPackage<'a> { + package: Cow<'a, str>, + /// The command that the step runs: it is looked for in `PATH`, whatever installed it + command: Cow<'a, str>, + /// What is not built without it + #[serde(rename = "for")] + purpose: Cow<'a, str>, +} + +/// A requirement that is not installed: the packages that would satisfy it, the first one being +/// the one to install. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Missing(Vec); + +impl Missing { + fn to_install(&self) -> &str { + self.0.first().map_or("", String::as_str) + } +} + +impl fmt::Display for Missing { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0.join(" or ")) + } +} + +/// The `requirements` that none of their packages satisfy, according to `installed`. +fn missing_of( + requirements: &[Requirement<'_>], + installed: impl Fn(&str) -> io::Result, +) -> io::Result> { + let mut missing = Vec::new(); + for requirement in requirements { + let packages = requirement.packages(); + let mut satisfied = false; + for package in &packages { + if installed(package)? { + satisfied = true; + break; + } + } + if !satisfied { + missing.push(Missing(packages.iter().map(ToString::to_string).collect())); + } + } + Ok(missing) } /// How a distribution tells whether a package is installed. The queries change nothing. @@ -151,24 +221,45 @@ fn resolve_target<'a>( /// /// # Errors /// When the OS is not in the manifest, or its package database cannot be queried. -pub fn missing_packages(os: &OsTarget, build: bool) -> Result, anyhow::Error> { +pub fn missing_packages(os: &OsTarget, build: bool) -> Result, anyhow::Error> { let target = resolve_target(dependencies_manifest()?, os)?; - let packages = if build { + let requirements = if build { &target.development } else { &target.runtime }; - let mut missing = Vec::new(); - for package in packages { - if !target - .query - .is_installed(package) - .with_context(|| format!("Cannot query the packages of {os}"))? - { - missing.push(package.to_string()); - } - } - Ok(missing) + missing_of(requirements, |package| target.query.is_installed(package)) + .with_context(|| format!("Cannot query the packages of {os}")) +} + +/// The optional packages of the build on `os` whose command is not found, with what is not built +/// without them. +/// +/// # Errors +/// When the OS is not in the manifest. +pub fn missing_optional(os: &OsTarget) -> Result, anyhow::Error> { + let target = resolve_target(dependencies_manifest()?, os)?; + Ok(target + .optional + .iter() + .filter(|optional| !util::command_exists(&optional.command)) + .map(|optional| (optional.package.to_string(), optional.purpose.to_string())) + .collect()) +} + +/// What is not built without the optional `package`, and the command that installs it. +/// +/// # Errors +/// When the OS is not in the manifest. +pub fn optional_message( + os: &OsTarget, + package: &str, + purpose: &str, +) -> Result { + Ok(format!( + "{package} is not installed: {purpose} are not built. To build them, install it, as root: {}", + install_command(os, &[package.to_string()])? + )) } /// The command that installs `packages` on `os`, to print for the administrator. @@ -194,19 +285,27 @@ pub fn install_command(os: &OsTarget, packages: &[String]) -> Result Result { + let to_install: Vec = missing + .iter() + .map(|missing| missing.to_install().to_string()) + .collect(); Ok(format!( "Missing packages to {} RootAsRole on {os}: {}. Install them, as root: {}", if build { "build" } else { "run" }, - missing.join(" "), - install_command(os, missing)? + missing + .iter() + .map(ToString::to_string) + .collect::>() + .join(", "), + install_command(os, &to_install)? )) } /// What to do with the `missing` packages: stop with the command that installs them, or only /// warn in a dry run. -fn report_missing(os: &OsTarget, build: bool, missing: &[String]) -> Result<(), anyhow::Error> { +fn report_missing(os: &OsTarget, build: bool, missing: &[Missing]) -> Result<(), anyhow::Error> { if missing.is_empty() { return Ok(()); } @@ -220,18 +319,22 @@ fn report_missing(os: &OsTarget, build: bool, missing: &[String]) -> Result<(), } /// Check the packages that `os` needs to build `RootAsRole` (`build`), or to run it. A system -/// whose packages cannot be queried is only warned about: the build tells then. +/// whose packages cannot be queried is only warned about: the build tells then. A missing optional +/// package of the build is only warned about. /// /// # Errors /// When packages are missing, with the command that installs them. pub fn check(os: &OsTarget, build: bool) -> Result<(), anyhow::Error> { match missing_packages(os, build) { - Ok(missing) => report_missing(os, build, &missing), - Err(e) => { - warn!("The packages of {os} cannot be checked: {e:#}"); - Ok(()) + Ok(missing) => report_missing(os, build, &missing)?, + Err(e) => warn!("The packages of {os} cannot be checked: {e:#}"), + } + if build { + for (package, purpose) in missing_optional(os)? { + warn!("{}", optional_message(os, &package, &purpose)?); } } + Ok(()) } #[cfg(test)] @@ -254,6 +357,22 @@ mod tests { assert!(!target.install.is_empty(), "{os}"); assert!(!target.runtime.is_empty(), "{os}"); assert!(!target.development.is_empty(), "{os}"); + // a C compiler, clang or gcc, links the binaries + assert!( + target + .development + .iter() + .any(|requirement| requirement.packages() == ["clang", "gcc"]), + "{os}" + ); + // pandoc only builds the manual pages + assert!( + target + .optional + .iter() + .any(|optional| optional.command == "pandoc"), + "{os}" + ); } assert_eq!( install_command(&OsTarget::Debian, &["clang".into(), "pandoc".into()]).unwrap(), @@ -287,15 +406,66 @@ mod tests { assert!(target.query.is_installed(itself).unwrap(), "{os}"); } + #[test] + fn test_one_package_of_a_group_is_enough() { + let requirements: Vec = + serde_json::from_str(r#"[["clang", "gcc"], "pkg-config"]"#).unwrap(); + let only = |names: &'static [&'static str]| { + move |package: &str| Ok::<_, io::Error>(names.contains(&package)) + }; + assert!( + missing_of(&requirements, only(&["gcc", "pkg-config"])) + .unwrap() + .is_empty() + ); + assert!( + missing_of(&requirements, only(&["clang", "pkg-config"])) + .unwrap() + .is_empty() + ); + assert_eq!( + missing_of(&requirements, only(&[])).unwrap(), + [ + Missing(vec!["clang".into(), "gcc".into()]), + Missing(vec!["pkg-config".into()]) + ] + ); + // a query that fails is not taken as a missing package + assert!(missing_of(&requirements, |_| Err(io::Error::other("no database"))).is_err()); + } + + #[test] + fn test_an_optional_package_is_found_by_its_command() { + assert!(util::command_exists("sh")); + assert!(!util::command_exists("rar-no-such-command")); + } + + #[test] + fn test_optional_package_message() { + let message = optional_message(&OsTarget::Debian, "pandoc", "the manual pages").unwrap(); + assert_eq!( + message, + "pandoc is not installed: the manual pages are not built. To build them, install it, as root: apt-get install pandoc" + ); + } + #[test] fn test_missing_packages_stop_unless_dry_run() { let _guard = DRY_RUN_TEST_LOCK.lock().unwrap(); - let missing = ["pandoc".to_string()]; + let missing = [ + Missing(vec!["clang".into(), "gcc".into()]), + Missing(vec!["pkg-config".into()]), + ]; set_dry_run(false); let error = report_missing(&OsTarget::Debian, true, &missing) .unwrap_err() .to_string(); - assert!(error.contains("apt-get install pandoc"), "{error}"); + // the group is named whole, and its first package is the one to install + assert!(error.contains("clang or gcc, pkg-config"), "{error}"); + assert!( + error.contains("apt-get install clang pkg-config"), + "{error}" + ); assert!(report_missing(&OsTarget::Debian, true, &[]).is_ok()); set_dry_run(true); assert!(report_missing(&OsTarget::Debian, true, &missing).is_ok()); diff --git a/xtask/src/installer/deps.json b/xtask/src/installer/deps.json index 526c1309..de302ebd 100644 --- a/xtask/src/installer/deps.json +++ b/xtask/src/installer/deps.json @@ -5,28 +5,32 @@ "query": "dpkg", "install": ["apt-get", "install"], "runtime": ["libpam0g", "libseccomp2"], - "development": ["clang", "pkg-config", "libpam0g-dev", "libseccomp-dev", "pandoc"] + "development": [["clang", "gcc"], "pkg-config", "libpam0g-dev", "libseccomp-dev"], + "optional": [{ "package": "pandoc", "command": "pandoc", "for": "the manual pages" }] }, "fedora": { "aliases": ["rockylinux", "rocky", "centos", "almalinux", "rhel", "redhat"], "query": "rpm", "install": ["dnf", "install"], "runtime": ["pam", "libseccomp"], - "development": ["clang", "pkgconfig", "pam-devel", "libseccomp-devel", "pandoc"] + "development": [["clang", "gcc"], "pkgconfig", "pam-devel", "libseccomp-devel"], + "optional": [{ "package": "pandoc", "command": "pandoc", "for": "the manual pages" }] }, "opensuse": { "aliases": ["suse"], "query": "rpm", "install": ["zypper", "install"], "runtime": ["pam", "libseccomp2"], - "development": ["clang", "pkg-config", "pam-devel", "libseccomp-devel", "pandoc"] + "development": [["clang", "gcc"], "pkg-config", "pam-devel", "libseccomp-devel"], + "optional": [{ "package": "pandoc", "command": "pandoc", "for": "the manual pages" }] }, "archlinux": { "aliases": ["arch"], "query": "pacman", "install": ["pacman", "-S", "--needed"], "runtime": ["pam", "libseccomp"], - "development": ["clang", "pkgconf", "pam", "libseccomp", "pandoc-cli"] + "development": [["clang", "gcc"], "pkgconf", "pam", "libseccomp"], + "optional": [{ "package": "pandoc-cli", "command": "pandoc", "for": "the manual pages" }] } } } diff --git a/xtask/src/installer/install.rs b/xtask/src/installer/install.rs index 925b5a94..f1c5cbf5 100644 --- a/xtask/src/installer/install.rs +++ b/xtask/src/installer/install.rs @@ -100,6 +100,11 @@ fn copy_docs() -> Result<(), anyhow::Error> { fn enter_directory() -> io::Result<()> { set_current_dir("target/man/") } + // the manual pages are not built without pandoc + if !Path::new("target/man").is_dir() { + info!("No manual page was built, none is installed"); + return Ok(()); + } enter_directory()?; for file in glob::glob("**/*") diff --git a/xtask/src/util.rs b/xtask/src/util.rs index 204d8a42..9808e3ea 100644 --- a/xtask/src/util.rs +++ b/xtask/src/util.rs @@ -965,6 +965,41 @@ pub fn get_os(os: Option<&OsTarget>) -> Result { }) } +/// Whether `command` is an executable file of a directory of `PATH`. +#[must_use] +pub fn command_exists(command: &str) -> bool { + let Some(path) = std::env::var_os("PATH") else { + return false; + }; + + for dir in std::env::split_paths(&path) { + let candidate = dir.join(command); + if is_executable(&candidate) { + return true; + } + } + + false +} + +fn is_executable(path: &Path) -> bool { + if let Ok(metadata) = std::fs::metadata(path) { + if !metadata.is_file() { + return false; + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + return metadata.permissions().mode() & 0o111 != 0; + } + #[cfg(not(unix))] + { + return true; + } + } + false +} + #[must_use] pub fn detect_priv_bin() -> Option { // is /usr/bin/dosr exist ? From d0d28a0c0ab70afb37b32aa378b83ae10174c403 Mon Sep 17 00:00:00 2001 From: LeChatP Date: Sun, 25 Oct 2026 19:31:17 +0100 Subject: [PATCH 58/88] fix: a task that changes the user gives the command the groups of the target user * fix: a task with setuid and without setgid ran the command as the target user with every group of the caller, which neither of them has alone; the command now gets the groups of the target user, its primary group first and then the groups that list it, as a login gives them (getgrouplist, like sudo-rs User::from_libc and set_target_user) * fix: a setgid selector with "default": "all" and without fallback kept the groups of the caller without -g; it now gives the groups of the target user too * feat: "from": "caller" or "target" in a setgid selector says whose groups the command gets when the task does not force them and the user does not choose any with -g; with from, the none default no longer requires -g; a task that keeps the user keeps the groups of the caller * fix: a lookup of the groups that fails refuses the command, it never falls back on the groups of the caller; the groups are read once the task is chosen, so the choice is the same, and dosr -i and the log show them * chore: no migration rewrites the tasks, the new default gives less (decided by the maintainer); the downgrade to 4.1.0 removes from, and a selector left with nothing else, so that 4.1.0 keeps the groups of the caller * tests: the groups of root for a setuid root, none for from: caller, without setuid or with forced groups, a failed lookup refused; from in the selector with and without -g and fallback; from in JSON and CBOR; the downgrade with the long and the short keys Co-Authored-By: Claude --- core/src/database/de.rs | 44 +++++++++++- core/src/database/migrations.rs | 105 +++++++++++++++++++++++++++ core/src/database/ser.rs | 11 ++- core/src/database/structs.rs | 20 +++++- src/dosr/finder/de/cred.rs | 123 ++++++++++++++++++++++++++------ src/dosr/finder/mod.rs | 118 +++++++++++++++++++++++++++++- 6 files changed, 392 insertions(+), 29 deletions(-) diff --git a/core/src/database/de.rs b/core/src/database/de.rs index 960ad3c2..f2336527 100644 --- a/core/src/database/de.rs +++ b/core/src/database/de.rs @@ -6,7 +6,7 @@ use serde::{Deserialize, de::DeserializeSeed}; use crate::database::{ actor::SGroups, - structs::{SCommand, SSetgidSet, SetBehavior}, + structs::{GroupsFrom, SCommand, SSetgidSet, SetBehavior}, }; use super::{ @@ -294,6 +294,8 @@ impl<'de> Deserialize<'de> for SSetgidSet { Add, #[serde(alias = "del", alias = "s")] Sub, + #[serde(alias = "o")] + From, } impl<'de> Visitor<'de> for SSetgidSetVisitor { @@ -311,12 +313,16 @@ impl<'de> Deserialize<'de> for SSetgidSet { let mut add = Vec::new(); let mut sub = Vec::new(); let mut fallback = None; + let mut from = None; while let Some(key) = map.next_key::()? { match key { Field::Default => { default_behavior = map.next_value()?; } + Field::From => { + from = Some(map.next_value::()?); + } Field::Fallback => { fallback.replace(map.next_value::()?); } @@ -335,6 +341,7 @@ impl<'de> Deserialize<'de> for SSetgidSet { fallback, add, sub, + from, }) } } @@ -639,6 +646,41 @@ mod tests { } } + #[test] + fn test_s_setgid_set_from() { + // alone, from says whose groups the command gets + let set: SSetgidSet = serde_json::from_value(json!({"from": "caller"})).unwrap(); + assert_eq!(set.default_behavior, SetBehavior::None); + assert_eq!(set.from, Some(GroupsFrom::Caller)); + assert_eq!( + serde_json::to_value(&set).unwrap(), + json!({"default": "none", "from": "caller"}) + ); + for json_data in [ + json!({"from": "target"}), + json!({"default": "none", "add": [["adm"]], "from": "caller"}), + // a fallback with from is not written as a mandatory group + json!({"default": "none", "fallback": ["adm"], "from": "caller"}), + ] { + let set: SSetgidSet = serde_json::from_value(json_data.clone()).unwrap(); + assert!(set.from.is_some(), "{json_data}"); + let again: SSetgidSet = + serde_json::from_value(serde_json::to_value(&set).unwrap()).unwrap(); + assert_eq!(again, set, "{json_data}"); + let cbor = cbor4ii::serde::to_vec(Vec::new(), &set).unwrap(); + let again: SSetgidSet = cbor4ii::serde::from_slice(&cbor).unwrap(); + assert_eq!(again, set, "{json_data}"); + } + // without from, nothing is written + let set: SSetgidSet = serde_json::from_value(json!({"default": "all"})).unwrap(); + assert_eq!(set.from, None); + assert_eq!( + serde_json::to_value(&set).unwrap(), + json!({"default": "all"}) + ); + assert!(serde_json::from_value::(json!({"from": "someone"})).is_err()); + } + #[test] fn test_invalid_selector_entries_are_errors() { // the policy comes from a file: a mistake is an error, never a panic diff --git a/core/src/database/migrations.rs b/core/src/database/migrations.rs index 352c01f2..f15dd4fb 100644 --- a/core/src/database/migrations.rs +++ b/core/src/database/migrations.rs @@ -75,6 +75,7 @@ pub const DOCUMENT_MIGRATIONS: &[Migration] = &[ up: unchanged, down: |_, document| { remove_password_options(document); + remove_groups_from(document); Ok(()) }, }, @@ -191,6 +192,53 @@ fn remove_password_options(document: &mut Value) { } } +/// `from` of a `setgid` selector is unknown before 4.2.0, whatever its key (the CBOR format writes +/// `o`). A selector with nothing else than `"default": "none"` is removed with it: 4.1.0 then +/// keeps the groups of the caller, as `from: caller` did, instead of requiring `-g`. 4.1.0 cannot +/// give the groups of the target user, which `from: target` and a task without `setgid` do. +fn remove_groups_from(document: &mut Value) { + let Value::Object(document) = document else { + return; + }; + for roles in ["roles", "r"] { + let Some(Value::Array(roles)) = document.get_mut(roles) else { + continue; + }; + for role in roles { + for tasks in ["tasks", "t"] { + let Some(Value::Array(tasks)) = role.get_mut(tasks) else { + continue; + }; + for task in tasks { + for cred in ["cred", "credentials", "i"] { + if let Some(Value::Object(cred)) = task.get_mut(cred) { + remove_cred_groups_from(cred); + } + } + } + } + } + } +} + +fn remove_cred_groups_from(cred: &mut serde_json::Map) { + for setgid in ["setgid", "g"] { + let Some(Value::Object(selector)) = cred.get_mut(setgid) else { + continue; + }; + let Some(from) = selector.remove("from").or_else(|| selector.remove("o")) else { + continue; + }; + info!("Removing from: {from} of a setgid selector, unknown before 4.2.0"); + let nothing_else = selector.iter().all(|(key, value)| { + matches!(key.as_str(), "default" | "d") && (value == "none" || value == 0) + }); + if nothing_else { + cred.remove(setgid); + } + } +} + fn path_only(command: &str) -> bool { let command = command.trim(); !command.is_empty() && !command.contains(char::is_whitespace) @@ -644,6 +692,63 @@ mod tests { assert_eq!(document["roles"][0]["tasks"][0]["options"], json!({})); } + #[test] + fn test_groups_from_is_removed_down() { + let task = |cred: Value| json!({"name": "t", "cred": cred}); + let mut document = json!({ + "roles": [{ + "name": "r", + "tasks": [ + task(json!({"setuid": "nobody", "setgid": {"from": "caller"}})), + task(json!({"setgid": {"default": "none", "add": [["adm"]], "from": "caller"}})), + task(json!({"setgid": {"default": "all", "from": "target"}})), + task(json!({"setgid": {"default": "none", "fallback": ["adm"], "from": "target"}})), + task(json!({"setgid": "adm"})) + ] + }, { + // the short keys of the CBOR format + "name": "c", + "t": [{"name": "t", "i": {"g": {"d": 0, "o": "caller"}}}] + }] + }); + let before = document.clone(); + Migration::migrate_from( + &Version::new(4, 1, 0), + &PACKAGE_VERSION, + &mut document, + DOCUMENT_MIGRATIONS, + ) + .unwrap(); + assert_eq!(document, before, "the upgrade writes no from"); + + Migration::migrate_from( + &PACKAGE_VERSION, + &Version::new(4, 1, 0), + &mut document, + DOCUMENT_MIGRATIONS, + ) + .unwrap(); + let creds: Vec<&Value> = document["roles"][0]["tasks"] + .as_array() + .unwrap() + .iter() + .map(|task| &task["cred"]) + .collect(); + // nothing else than from: no setgid, 4.1.0 keeps the groups of the caller + assert_eq!(creds[0], &json!({"setuid": "nobody"})); + assert_eq!( + creds[1], + &json!({"setgid": {"default": "none", "add": [["adm"]]}}) + ); + assert_eq!(creds[2], &json!({"setgid": {"default": "all"}})); + assert_eq!( + creds[3], + &json!({"setgid": {"default": "none", "fallback": ["adm"]}}) + ); + assert_eq!(creds[4], &json!({"setgid": "adm"})); + assert_eq!(document["roles"][1]["t"][0]["i"], json!({})); + } + #[test] fn test_4_1_0_documents_up_and_down() { let original = json!({ diff --git a/core/src/database/ser.rs b/core/src/database/ser.rs index 0c86fd33..e71ffa26 100644 --- a/core/src/database/ser.rs +++ b/core/src/database/ser.rs @@ -141,7 +141,10 @@ impl Serialize for SSetgidSet { S: serde::Serializer, { if let Some(fallback) = self.fallback.as_ref().filter(|_| { - self.default_behavior.is_none() && self.sub.is_empty() && self.add.is_empty() + self.default_behavior.is_none() + && self.sub.is_empty() + && self.add.is_empty() + && self.from.is_none() }) { serializer.serialize_some(fallback) } else if serializer.is_human_readable() { @@ -156,6 +159,9 @@ impl Serialize for SSetgidSet { if !self.sub.is_empty() { map.serialize_entry("del", &self.sub)?; } + if let Some(from) = &self.from { + map.serialize_entry("from", from)?; + } map.end() } else { let mut map = serializer.serialize_map(None)?; @@ -169,6 +175,9 @@ impl Serialize for SSetgidSet { if !self.sub.is_empty() { map.serialize_entry("s", &self.sub)?; } + if let Some(from) = &self.from { + map.serialize_entry("o", from)?; + } map.end() } } diff --git a/core/src/database/structs.rs b/core/src/database/structs.rs index dab6e5f4..871ed194 100644 --- a/core/src/database/structs.rs +++ b/core/src/database/structs.rs @@ -348,18 +348,34 @@ impl From for SGroupsEither { } } +/// Whose groups the command gets when the task does not force them and the user does not choose +/// any with `-g`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum GroupsFrom { + /// The groups of the user that the command runs as, as a login gives them: its primary group + /// and the groups that list it as a member + #[default] + Target, + /// The groups of the user who runs dosr + Caller, +} + #[derive(Debug, Clone, Builder, PartialEq, Eq)] pub struct SSetgidSet { #[builder(start_fn)] pub default_behavior: SetBehavior, - /// The groups when the user does not choose any with `-g`. Without it, `-g` is required, - /// or with the `all` default, the groups of the caller are kept. + /// The groups when the user does not choose any with `-g`. Without it, the groups that + /// `from` names; without `from` either, `-g` is required with the `none` default, and the + /// groups of the target user are taken with the `all` default. #[builder(into)] pub fallback: Option, #[builder(default, with = FromIterator::from_iter)] pub add: Vec, #[builder(default, with = FromIterator::from_iter)] pub sub: Vec, + /// Whose groups the command gets without `-g` and without `fallback` + pub from: Option, } #[derive(PartialEq, Eq, Debug, Builder, Default)] diff --git a/src/dosr/finder/de/cred.rs b/src/dosr/finder/de/cred.rs index a478f3e2..92eeee95 100644 --- a/src/dosr/finder/de/cred.rs +++ b/src/dosr/finder/de/cred.rs @@ -8,7 +8,7 @@ use rootasrole_core::{ database::{ actor::{DGroupType, DGroups, DUserType}, score::{CapsMin, SetgidMin, SetuidMin, TaskScore}, - structs::{SCapabilities, SetBehavior}, + structs::{GroupsFrom, SCapabilities, SetBehavior}, }, util::capabilities_are_exploitable, }; @@ -28,6 +28,8 @@ pub(super) struct CredFinderDeserializerReturn<'a> { pub struct CredData<'a> { pub setuid: Option>, pub setgroups: Option>, + /// Whose groups the command gets when `setgroups` is `None` + pub groups_from: Option, pub caps: Option, #[builder(default)] pub extra_values: HashMap, Value>, @@ -37,6 +39,8 @@ pub struct CredData<'a> { pub struct CredOwnedData { pub setuid: Option, pub setgroups: Option>, + /// Whose groups the command gets when `setgroups` is `None` + pub groups_from: Option, pub caps: Option, #[builder(default)] pub extra_values: HashMap, @@ -96,6 +100,7 @@ impl<'de: 'a, 'a> DeserializeSeed<'de> for CredFinderDeserializerReturn<'a> { { let mut setuid = None; let mut setgroups = None; + let mut groups_from = None; let mut caps = None; let mut score = TaskScore::default(); let mut ok = true; @@ -114,9 +119,10 @@ impl<'de: 'a, 'a> DeserializeSeed<'de> for CredFinderDeserializerReturn<'a> { } Field::Setgid => { debug!("CredFinderVisitor: setgid"); - let (groups, setuser_min, groups_ok) = + let (groups, setuser_min, groups_ok, from) = map.next_value_seed(SetGroupsDeserializerReturn { cli: self.cli })?; setgroups = groups; + groups_from = from; score.setuser_min.gid = setuser_min; if !groups_ok { ok = false; @@ -141,6 +147,7 @@ impl<'de: 'a, 'a> DeserializeSeed<'de> for CredFinderDeserializerReturn<'a> { cred: CredData { setuid, setgroups, + groups_from, caps, extra_values, }, @@ -159,24 +166,33 @@ impl<'de: 'a, 'a> DeserializeSeed<'de> for CredFinderDeserializerReturn<'a> { fn fixed_groups<'a, E: serde::de::Error>( cli: &Cli, groups: DGroups<'a>, -) -> Result<(Option>, Option, bool), E> { +) -> Result, E> { if let Some(requested) = cli.opt_filter.as_ref().and_then(|f| f.group.as_ref()) { let ids: Vec = (&groups).try_into().map_err(E::custom)?; if *requested != ids { - return Ok((None, None, false)); + return Ok((None, None, false, None)); } } let score = Some(SetgidMin::from(&groups)); - Ok((Some(groups), score, true)) + Ok((Some(groups), score, true, None)) } +/// The groups of a `setgid`, their score, whether the task can be used with the `-g` of the +/// command line, and `from` of a selector. +type SetGroupsResult<'a> = ( + Option>, + Option, + bool, + Option, +); + // New deserializer for SetGroups that returns values instead of using &mut struct SetGroupsDeserializerReturn<'a> { cli: &'a Cli, } impl<'de: 'a, 'a> DeserializeSeed<'de> for SetGroupsDeserializerReturn<'a> { - type Value = (Option>, Option, bool); + type Value = SetGroupsResult<'a>; #[allow(clippy::too_many_lines)] fn deserialize(self, deserializer: D) -> Result where @@ -194,12 +210,14 @@ impl<'de: 'a, 'a> DeserializeSeed<'de> for SetGroupsDeserializerReturn<'a> { Add, #[serde(alias = "s", alias = "sub")] Del, + #[serde(alias = "o")] + From, } struct SGroupsChooserVisitor<'a> { cli: &'a Cli, } impl<'de: 'a, 'a> serde::de::Visitor<'de> for SGroupsChooserVisitor<'a> { - type Value = (Option>, Option, bool); + type Value = SetGroupsResult<'a>; fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { formatter.write_str("SGroups structure") @@ -261,7 +279,7 @@ impl<'de: 'a, 'a> DeserializeSeed<'de> for SetGroupsDeserializerReturn<'a> { } } if groups.is_empty() { - return Ok((None, None, false)); + return Ok((None, None, false, None)); } fixed_groups(self.cli, DGroups::from(groups)) } @@ -273,9 +291,14 @@ impl<'de: 'a, 'a> DeserializeSeed<'de> for SetGroupsDeserializerReturn<'a> { let mut score = None; let mut ok = false; let mut default_all = false; + let mut from = None; let filter = self.cli.opt_filter.as_ref().and_then(|x| x.group.as_ref()); 'fields: while let Some(key) = map.next_key()? { match key { + Field::From => { + debug!("SGroupsChooserVisitor: from"); + from = Some(map.next_value::()?); + } Field::Default => { debug!("SGroupsChooserVisitor: default"); let default = map.next_value::()?; @@ -361,7 +384,11 @@ impl<'de: 'a, 'a> DeserializeSeed<'de> for SetGroupsDeserializerReturn<'a> { score.replace((&requested).into()); groups = Some(requested); } - Ok((groups, score, ok)) + // without -g nor fallback, the groups that from names + if filter.is_none() && groups.is_none() && from.is_some() { + ok = true; + } + Ok((groups, score, ok, from)) } } deserializer.deserialize_any(SGroupsChooserVisitor { cli: self.cli }) @@ -699,7 +726,7 @@ mod test { let deserializer = SetGroupsDeserializerReturn { cli: &cli }; let result = deserializer.deserialize(&mut serde_json::Deserializer::from_str(json)); assert!(result.is_ok()); - let (groups, score, ok) = result.unwrap(); + let (groups, score, ok, _) = result.unwrap(); assert!(ok); let groups1 = DGroups::from(vec![1.into(), 2.into()]); assert_eq!(score, Some((&groups1).into())); @@ -742,12 +769,64 @@ mod test { FilterMatcher::builder().group(gids).unwrap().build() }); let cli = Cli::builder().maybe_opt_filter(filter).build(); - let (groups, score, ok) = SetGroupsDeserializerReturn { cli: &cli } + let (groups, score, ok, _) = SetGroupsDeserializerReturn { cli: &cli } .deserialize(&mut serde_json::Deserializer::from_str(json)) .unwrap(); ok.then(|| ((&groups.unwrap()).try_into().unwrap(), score.unwrap())) } + /// The result of a setgid selector for the `-g` of the command line, `requested`. + fn selector(json: &str, requested: Option<&[u32]>) -> (bool, bool, Option) { + let filter = requested.map(|gids| { + let gids: Vec = gids.iter().map(|gid| (*gid).into()).collect(); + FilterMatcher::builder().group(gids).unwrap().build() + }); + let cli = Cli::builder().maybe_opt_filter(filter).build(); + let (groups, _, ok, from) = SetGroupsDeserializerReturn { cli: &cli } + .deserialize(&mut serde_json::Deserializer::from_str(json)) + .unwrap(); + (ok, groups.is_some(), from) + } + + #[test] + fn test_group_selector_from() { + let gid = get_non_root_gid(0).unwrap(); + // without -g nor fallback, the task is usable with the groups that from names + assert_eq!( + selector(r#"{"from": "caller"}"#, None), + (true, false, Some(GroupsFrom::Caller)) + ); + assert_eq!( + selector( + &format!(r#"{{"default": "none", "add": [[{gid}]], "o": "target"}}"#), + None + ), + (true, false, Some(GroupsFrom::Target)) + ); + // without from, the none default still requires -g + assert_eq!( + selector(&format!(r#"{{"default": "none", "add": [[{gid}]]}}"#), None), + (false, false, None) + ); + // the all default without fallback: no groups here, the target user gives them + assert_eq!(selector(r#"{"default": "all"}"#, None), (true, false, None)); + // from does not allow a group that the selector does not list + assert_eq!( + selector(r#"{"from": "caller"}"#, Some(&[gid])), + (false, false, Some(GroupsFrom::Caller)) + ); + // a fallback is still taken first + assert_eq!( + selector( + &format!(r#"{{"default": "none", "fallback": [{gid}], "from": "caller"}}"#), + None + ), + (true, true, Some(GroupsFrom::Caller)) + ); + // a forced group has no from + assert_eq!(selector(&gid.to_string(), None), (true, true, None)); + } + #[test] fn test_group_selector_all_applies_the_requested_groups() { let fallback = get_non_root_gid(0).unwrap(); @@ -806,7 +885,7 @@ mod test { let deserializer = SetGroupsDeserializerReturn { cli: &cli }; let result = deserializer.deserialize(&mut serde_json::Deserializer::from_str(&json)); assert!(result.is_ok()); - let (groups, score, ok) = result.unwrap(); + let (groups, score, ok, _) = result.unwrap(); assert!(ok); let groups1 = DGroups::Single("root".into()); assert_eq!(score, Some((&groups1).into())); @@ -817,7 +896,7 @@ mod test { let deserializer = SetGroupsDeserializerReturn { cli: &cli }; let result = deserializer.deserialize(&mut serde_json::Deserializer::from_str(&json)); assert!(result.is_ok()); - let (groups, score, ok) = result.unwrap(); + let (groups, score, ok, _) = result.unwrap(); assert!(ok); let groups1 = DGroups::Single(gid1.into()); assert_eq!(score, Some((&groups1).into())); @@ -828,7 +907,7 @@ mod test { let deserializer = SetGroupsDeserializerReturn { cli: &cli }; let result = deserializer.deserialize(&mut serde_json::Deserializer::from_str(&json)); assert!(result.is_ok()); - let (groups, score, ok) = result.unwrap(); + let (groups, score, ok, _) = result.unwrap(); assert!(!ok); assert_eq!(score, None); assert_eq!(groups, None); @@ -839,7 +918,7 @@ mod test { let deserializer = SetGroupsDeserializerReturn { cli: &cli }; let result = deserializer.deserialize(&mut serde_json::Deserializer::from_str(json)); assert!(result.is_ok(), "Failed to deserialize: {result:?}"); - let (groups, score, ok) = result.unwrap(); + let (groups, score, ok, _) = result.unwrap(); assert!(ok); let groups1 = DGroups::Single("root".into()); assert_eq!(score, Some((&groups1).into())); @@ -850,7 +929,7 @@ mod test { let deserializer = SetGroupsDeserializerReturn { cli: &cli }; let result = deserializer.deserialize(&mut serde_json::Deserializer::from_str(json)); assert!(result.is_ok()); - let (groups, score, ok) = result.unwrap(); + let (groups, score, ok, _) = result.unwrap(); assert!(!ok); assert_eq!(score, None); assert_eq!(groups, None); @@ -861,7 +940,7 @@ mod test { let deserializer = SetGroupsDeserializerReturn { cli: &cli }; let result = deserializer.deserialize(&mut serde_json::Deserializer::from_str(json)); assert!(result.is_ok()); - let (groups, score, ok) = result.unwrap(); + let (groups, score, ok, _) = result.unwrap(); assert!(!ok); assert_eq!(score, None); assert_eq!(groups, None); @@ -871,7 +950,7 @@ mod test { let deserializer = SetGroupsDeserializerReturn { cli: &cli }; let result = deserializer.deserialize(&mut serde_json::Deserializer::from_str(json)); assert!(result.is_ok()); - let (groups, score, ok) = result.unwrap(); + let (groups, score, ok, _) = result.unwrap(); assert!(ok); let groups1 = DGroups::Single(0.into()); assert_eq!(score, Some((&groups1).into())); @@ -889,7 +968,7 @@ mod test { let deserializer = SetGroupsDeserializerReturn { cli: &cli }; let result = deserializer.deserialize(&mut serde_json::Deserializer::from_str(json)); assert!(result.is_ok(), "Failed to deserialize: {result:?}"); - let (groups, score, ok) = result.unwrap(); + let (groups, score, ok, _) = result.unwrap(); assert!(ok); let groups1 = DGroups::from(vec!["root".into(), 1.into()]); assert_eq!(score, Some((&groups1).into())); @@ -900,7 +979,7 @@ mod test { let deserializer = SetGroupsDeserializerReturn { cli: &cli }; let result = deserializer.deserialize(&mut serde_json::Deserializer::from_str(json)); assert!(result.is_ok(), "Failed to deserialize: {result:?}"); - let (groups, score, ok) = result.unwrap(); + let (groups, score, ok, _) = result.unwrap(); assert!(ok); let groups1 = DGroups::from(vec!["root".into(), 1.into()]); assert_eq!(score, Some((&groups1).into())); @@ -911,7 +990,7 @@ mod test { let deserializer = SetGroupsDeserializerReturn { cli: &cli }; let result = deserializer.deserialize(&mut serde_json::Deserializer::from_str(json)); assert!(result.is_ok()); - let (groups, score, ok) = result.unwrap(); + let (groups, score, ok, _) = result.unwrap(); assert!(!ok); assert_eq!(score, None); assert_eq!(groups, None); @@ -929,7 +1008,7 @@ mod test { let deserializer = SetGroupsDeserializerReturn { cli: &cli }; let result = deserializer.deserialize(&mut serde_json::Deserializer::from_str(json)); assert!(result.is_ok()); - let (groups, score, ok) = result.unwrap(); + let (groups, score, ok, _) = result.unwrap(); assert!(ok); assert_eq!(score, None); assert_eq!(groups, None); diff --git a/src/dosr/finder/mod.rs b/src/dosr/finder/mod.rs index 920d5e2c..87d7d101 100644 --- a/src/dosr/finder/mod.rs +++ b/src/dosr/finder/mod.rs @@ -4,6 +4,7 @@ use std::{ cmp::Ordering, collections::HashMap, + ffi::CString, fs, io::BufReader, path::{Path, PathBuf}, @@ -13,6 +14,10 @@ use api::{Api, ApiEvent}; use bon::Builder; use de::{ConfigFinderDeserializer, DConfigFinder, DLinkedCommand, DLinkedRole, DLinkedTask}; use log::{debug, error, warn}; +use nix::{ + errno::Errno, + unistd::{Group, User, getgrouplist}, +}; use options::BorrowedOptStack; use rootasrole_core::{ Cred, @@ -21,6 +26,7 @@ use rootasrole_core::{ env::EnvFilter, options::{SAuthentication, SBounding, SPrivileged, STimeout, SUMask}, score::{ActorMatchMin, CmdMin, CmdOrder, HardenedBool, Score, hardened_bool_from_bool}, + structs::GroupsFrom, workdir::ResolvedWorkdir, }, util::{StorageMethod, check_immutable, check_others_permissions, read_with_privileges}, @@ -215,10 +221,36 @@ where SrError::ConfigurationError } }) - .and_then(BestExecSettings::refuse_tie); + .and_then(BestExecSettings::refuse_tie) + .and_then(BestExecSettings::with_target_groups); } - parse_file(&file_path).and_then(BestExecSettings::refuse_tie) + parse_file(&file_path) + .and_then(BestExecSettings::refuse_tie) + .and_then(BestExecSettings::with_target_groups) +} + +/// The groups of `user` as a login gives them (`initgroups(3)`): its primary group first, then +/// the groups that list it as a member, like sudo-rs `User::from_libc` (sudo-rs/src/system/mod.rs). +/// A gid without a name is named by its number. +fn login_groups(user: &User) -> nix::Result> { + let name = CString::new(user.name.as_bytes()).map_err(|_| Errno::EINVAL)?; + let mut gids = vec![user.gid]; + for gid in getgrouplist(&name, user.gid)? { + if !gids.contains(&gid) { + gids.push(gid); + } + } + gids.into_iter() + .map(|gid| { + Ok(Group::from_gid(gid)?.unwrap_or_else(|| Group { + name: gid.to_string(), + passwd: CString::default(), + gid, + mem: Vec::new(), + })) + }) + .collect() } impl BestExecSettings { @@ -231,7 +263,27 @@ impl BestExecSettings { env_vars: impl IntoIterator, impl Into)>, env_path: &[&str], ) -> SrResult { - Self::evaluate(cli, cred, data, env_vars, env_path).and_then(Self::refuse_tie) + Self::evaluate(cli, cred, data, env_vars, env_path) + .and_then(Self::refuse_tie) + .and_then(Self::with_target_groups) + } + + /// The task changes the user without giving the groups: the command gets the groups of the + /// target user, as a login gives them, unless `from` names the groups of the caller. A task + /// that keeps the user keeps the groups of the caller. A lookup that fails refuses the + /// command, it never falls back on the groups of the caller. + fn with_target_groups(mut self) -> SrResult { + if self.cred.setgroups.is_none() + && self.cred.groups_from != Some(GroupsFrom::Caller) + && let Some(user) = &self.cred.setuid + { + let groups = login_groups(user).map_err(|e| { + error!("Cannot read the groups of the user {}: {e}", user.name); + SrError::SystemError + })?; + self.cred.setgroups = Some(groups); + } + Ok(self) } /// Refuse the settings when another task is as good as the best one: none of them needs @@ -524,6 +576,7 @@ impl BestExecSettings { DGroups::Single(g) => vec![g.fetch_group()].into_iter().flatten().collect(), DGroups::Multiple(g) => g.iter().filter_map(DGroupType::fetch_group).collect(), }); + self.cred.groups_from = data.cred.groups_from; self.cred.caps = data.cred.caps; opt_stack.set_role(data); opt_stack.set_task(data); @@ -709,6 +762,65 @@ mod tests { assert!(result.is_err()); } + #[test] + fn test_a_task_that_changes_the_user_gets_its_groups() { + let root = User::from_uid(nix::unistd::Uid::from_raw(0)) + .unwrap() + .unwrap(); + let settings = |setuid: Option, groups_from, setgroups| BestExecSettings { + cred: CredOwnedData::builder() + .maybe_setuid(setuid) + .maybe_groups_from(groups_from) + .maybe_setgroups(setgroups) + .build(), + ..Default::default() + }; + // setuid without setgid: the groups of the target user, its primary group first + for groups_from in [None, Some(GroupsFrom::Target)] { + let groups = settings(Some(root.clone()), groups_from, None) + .with_target_groups() + .unwrap() + .cred + .setgroups + .unwrap(); + assert_eq!(groups[0].gid, root.gid, "{groups_from:?}"); + assert_eq!(groups, login_groups(&root).unwrap()); + } + // from: caller, a task that keeps the user, and forced groups are left as they are + let caller = settings(Some(root.clone()), Some(GroupsFrom::Caller), None); + assert!( + caller + .with_target_groups() + .unwrap() + .cred + .setgroups + .is_none() + ); + let same_user = settings(None, None, None); + assert!( + same_user + .with_target_groups() + .unwrap() + .cred + .setgroups + .is_none() + ); + let forced = Group::from_gid(root.gid).unwrap().unwrap(); + let forced_settings = settings(Some(root.clone()), None, Some(vec![forced.clone()])); + assert_eq!( + forced_settings.with_target_groups().unwrap().cred.setgroups, + Some(vec![forced]) + ); + // a lookup that fails refuses the command instead of keeping the groups of the caller + let mut broken = root; + broken.name = "bro\0ken".into(); + assert!( + settings(Some(broken), None, None) + .with_target_groups() + .is_err() + ); + } + #[test] fn test_retrieve_settings_with_matching_role() { let cli = dummy_cli(); From 85d60783a15a342cd9ff3d48dc1a35f0bbaad3cb Mon Sep 17 00:00:00 2001 From: LeChatP Date: Sun, 25 Oct 2026 19:31:18 +0100 Subject: [PATCH 59/88] feat: chsr cred set --setgid-from caller|target * feat: --setgid-from writes from in the setgid selector of the task, or a selector with only from when the task has none; it cannot go with --setgid, which forces the groups; the groups forced before are replaced, with a warning * feat: cred unset --setgid-from removes from, and a selector left with nothing else * fix: a command with only --setgid-from was taken for task add or del, which answered Invalid action * chore: the help of cred lists --setuid, --setgid, --caps and --setgid-from * tests: set on a task without setgid and on a selector, the refusal with --setgid, unset, and unset without from Co-Authored-By: Claude --- src/chsr/cli/cli.pest | 5 +++- src/chsr/cli/data.rs | 4 ++- src/chsr/cli/mod.rs | 49 ++++++++++++++++++++++++++++++++++++ src/chsr/cli/pair.rs | 9 ++++++- src/chsr/cli/process.rs | 5 ++++ src/chsr/cli/process/json.rs | 36 +++++++++++++++++++++++++- src/chsr/cli/usage.rs | 15 ++++++++++- 7 files changed, 118 insertions(+), 5 deletions(-) diff --git a/src/chsr/cli/cli.pest b/src/chsr/cli/cli.pest index f1f22875..5561791d 100644 --- a/src/chsr/cli/cli.pest +++ b/src/chsr/cli/cli.pest @@ -151,12 +151,15 @@ cred_keyword = { ("credentials" | "cred") } credentials_operations = _{ cred_keyword ~ (cred_set_operations | cred_caps_operations | list) } cred_set_operations = _{ (set | del) ~ cred_set_args } cred_set_args = _{ - (cred_c | cred_u | cred_g)+ + (cred_c | cred_u | cred_from | cred_g)+ | help } cred_c = ${ ("--caps" ~ assignment | "-c" ~ WHITESPACE+) ~ capabilities? } cred_u = ${ ("--setuid" ~ assignment | "-u" ~ WHITESPACE+) ~ actor_name } cred_g = ${ ("--setgid" ~ assignment | "-g" ~ WHITESPACE+) ~ name_combination } +// before cred_g, which --setgid-from starts like +cred_from = ${ "--setgid-from" ~ ("=" | WHITESPACE+) ~ groups_from } +groups_from = { "caller" | "target" } capabilities = _{ capability ~ (","?) ~ capabilities | capability } capability = @{ ^"CAP_"? ~ (LETTER | "_")+ } diff --git a/src/chsr/cli/data.rs b/src/chsr/cli/data.rs index 808cbe47..6a28ba5e 100644 --- a/src/chsr/cli/data.rs +++ b/src/chsr/cli/data.rs @@ -13,7 +13,7 @@ use rootasrole_core::{ EnvBehavior, EnvKey, OptType, PathBehavior, SAuthentication, SBounding, SInfo, SPrivileged, SUMask, TimestampType, WorkdirBehavior, }, - structs::{IdTask, SetBehavior}, + structs::{GroupsFrom, IdTask, SetBehavior}, }, util::StorageMethod, }; @@ -143,6 +143,8 @@ pub struct Inputs { pub cred_caps: Option, pub cred_setuid: Option, pub cred_setgid: Option, + /// Whose groups the command gets when the task does not force them + pub cred_groups_from: Option, pub cred_policy: Option, pub options: bool, pub options_type: Option, diff --git a/src/chsr/cli/mod.rs b/src/chsr/cli/mod.rs index 22fec4b6..8390e2dd 100644 --- a/src/chsr/cli/mod.rs +++ b/src/chsr/cli/mod.rs @@ -1953,6 +1953,55 @@ mod tests { ); ctx.assert_command_not_contains(&command); } + #[test] + fn test_cred_setgid_from() { + let (mut ctx, _defer) = TestContext::new("cred_setgid_from"); + let setgid = |ctx: &TestContext| { + serde_json::to_value(&ctx.task().as_ref().borrow().cred.setgid).unwrap() + }; + ctx.assert_command_success("r complete t t_complete cred unset --setgid group1"); + // a task without setgid gets a selector with from + ctx.assert_command_success("r complete t t_complete cred set --setgid-from caller"); + assert_eq!( + setgid(&ctx), + serde_json::json!({"default": "none", "from": "caller"}) + ); + // forcing groups and taking them from someone cannot go together + assert!( + ctx.run_command( + "r complete t t_complete cred set --setgid group1 --setgid-from caller" + ) + .is_err() + ); + // a selector keeps its fields, only from changes + ctx.task().as_ref().borrow_mut().cred.setgid = Some(SGroupsEither::GroupSelector( + SSetgidSet::builder(SetBehavior::None) + .add([SGroups::from(vec![ + rootasrole_core::database::actor::SGroupType::from("adm"), + ])]) + .build(), + )); + ctx.assert_command_success("r complete t t_complete cred set --setgid-from=target"); + assert_eq!( + setgid(&ctx), + serde_json::json!({"default": "none", "add": ["adm"], "from": "target"}) + ); + ctx.assert_command_success("r complete t t_complete cred unset --setgid-from target"); + assert_eq!( + setgid(&ctx), + serde_json::json!({"default": "none", "add": ["adm"]}) + ); + // a selector with nothing else than from is removed with it + ctx.assert_command_success("r complete t t_complete cred unset --setgid group1"); + ctx.assert_command_success("r complete t t_complete cred set --setgid-from caller"); + ctx.assert_command_success("r complete t t_complete cred unset --setgid-from caller"); + ctx.assert_setgid_is_none(); + assert!( + ctx.run_command("r complete t t_complete cred unset --setgid-from caller") + .is_err() + ); + } + #[test] fn test_cmd_whitelist_keeps_a_regular_expression_as_typed() { let (mut ctx, _defer) = diff --git a/src/chsr/cli/pair.rs b/src/chsr/cli/pair.rs index 2737f0fb..2fb73a21 100644 --- a/src/chsr/cli/pair.rs +++ b/src/chsr/cli/pair.rs @@ -14,7 +14,7 @@ use rootasrole_core::{ options::{ EnvBehavior, EnvKey, OptType, PathBehavior, SUMask, TimestampType, WorkdirBehavior, }, - structs::{IdTask, SetBehavior}, + structs::{GroupsFrom, IdTask, SetBehavior}, }, util::{StorageMethod, is_valid_env_name}, }; @@ -449,6 +449,13 @@ fn match_pair(pair: &Pair, inputs: &mut Inputs) -> Result<(), Box { + inputs.cred_groups_from = Some(if pair.as_str() == "caller" { + GroupsFrom::Caller + } else { + GroupsFrom::Target + }); + } Rule::cred_g => { let groups: Vec = actor_names(pair) .iter() diff --git a/src/chsr/cli/process.rs b/src/chsr/cli/process.rs index d80241bd..4e4db2b8 100644 --- a/src/chsr/cli/process.rs +++ b/src/chsr/cli/process.rs @@ -174,6 +174,7 @@ pub fn process_input(storage: &mut FileSettings, inputs: Inputs) -> Result Result Result Result Result, cred_setuid: Option, cred_setgid: Option, + groups_from: Option, ) -> Result> { debug!("chsr role r1 task t1 cred"); + if groups_from.is_some() && cred_setgid.is_some() { + return Err("--setgid-from only applies when --setgid does not force the groups".into()); + } match rconfig.task(role_id, task_id) { Ok(task) => { if let Some(caps) = cred_caps { @@ -346,6 +351,19 @@ pub fn cred_set( task.as_ref().borrow_mut().cred.setgid = Some(SGroupsEither::MandatoryGroups(setgid)); } + if let Some(from) = groups_from { + let mut task = task.as_ref().borrow_mut(); + if let Some(SGroupsEither::GroupSelector(selector)) = task.cred.setgid.as_mut() { + selector.from = Some(from); + } else { + if task.cred.setgid.is_some() { + warn!("The groups that the task forced are replaced by --setgid-from"); + } + task.cred.setgid = Some(SGroupsEither::GroupSelector( + SSetgidSet::builder(SetBehavior::None).from(from).build(), + )); + } + } Ok(true) } Err(e) => Err(e), @@ -359,6 +377,7 @@ pub fn cred_unset( cred_caps: Option, cred_setuid: Option<&rootasrole_core::database::actor::SUserType>, cred_setgid: Option<&rootasrole_core::database::actor::SGroups>, + groups_from: bool, ) -> Result> { debug!("chsr role r1 task t1 cred unset"); match rconfig.task(role_id, task_id) { @@ -378,6 +397,21 @@ pub fn cred_unset( if cred_setgid.is_some() { task.as_ref().borrow_mut().cred.setgid = None; } + if groups_from { + let mut task = task.as_ref().borrow_mut(); + let Some(SGroupsEither::GroupSelector(selector)) = task.cred.setgid.as_mut() else { + return Err("The task has no --setgid-from to unset".into()); + }; + selector.from = None; + // a selector with nothing else is no setgid + if selector.default_behavior.is_none() + && selector.fallback.is_none() + && selector.add.is_empty() + && selector.sub.is_empty() + { + task.cred.setgid = None; + } + } Ok(true) } Err(e) => Err(e), diff --git a/src/chsr/cli/usage.rs b/src/chsr/cli/usage.rs index 44fa26b6..97cd4816 100644 --- a/src/chsr/cli/usage.rs +++ b/src/chsr/cli/usage.rs @@ -257,13 +257,26 @@ const USAGE_CREDENTIALS: UsageSection = UsageSection { description: "Set or unset credentials details.", indent: 1, }, + UsageItem { + name: "--setuid [user], --setgid [groups], --caps [capabilities]", + description: "The user, the groups (the first one is the primary group) and the capabilities of the command.", + indent: 2, + }, + UsageItem { + name: "--setgid-from [caller|target]", + description: "Whose groups the command gets when --setgid does not force them: the target user's by default.", + indent: 2, + }, UsageItem { name: "caps", description: "Manage capabilities for credentials.", indent: 1, }, ], - examples: &["chsr role admin task backup credentials show"], + examples: &[ + "chsr role admin task backup credentials show", + "chsr role admin task backup credentials set --setuid backup --setgid-from caller", + ], }; const USAGE_CREDENTIALS_CAPS: UsageSection = UsageSection { From 2669ef31cc9bf94280d5c6cde21c19a920d73b74 Mon Sep 17 00:00:00 2001 From: LeChatP Date: Sun, 25 Oct 2026 19:31:18 +0100 Subject: [PATCH 60/88] tests: end-to-end checks of the groups of a task that changes the user * tests: a task with setuid nobody and without setgid gives the groups of nobody, not those of the caller; with "from": "caller", those of the caller Co-Authored-By: Claude --- tests/vagrant/e2e.sh | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/tests/vagrant/e2e.sh b/tests/vagrant/e2e.sh index 9b05cff8..fc7d5649 100755 --- a/tests/vagrant/e2e.sh +++ b/tests/vagrant/e2e.sh @@ -112,6 +112,16 @@ setup() { }, "commands": ["/usr/bin/env"] }, + { + "name": "target-groups", + "cred": { "setuid": "nobody" }, + "commands": ["/usr/bin/id ^-G$"] + }, + { + "name": "caller-groups", + "cred": { "setuid": "nobody", "setgid": { "from": "caller" } }, + "commands": ["/usr/bin/id ^-G$"] + }, { "name": "password", "options": { @@ -386,6 +396,27 @@ chsr_is_confined_by_landlock() { fi } +# the groups of id -G, sorted +sorted_groups() { + tr ' ' '\n' | sort -n | tr '\n' ' ' +} + +# a task that changes the user without setgid gives the groups of the target user, as a login +# does, not the ones of the caller +groups_of_the_target_user() { + local got expected + got=$(as_user dosr -r e2e -t target-groups /usr/bin/id -G | sorted_groups) + expected=$(id -G nobody | sorted_groups) + [ "$got" = "$expected" ] && [ "$got" != "$(as_user id -G | sorted_groups)" ] +} + +# "from": "caller" keeps the groups of the caller +groups_of_the_caller_with_from() { + local got + got=$(as_user dosr -r e2e -t caller-groups /usr/bin/id -G | sorted_groups) + [ "$got" = "$(as_user id -G | sorted_groups)" ] +} + # -- ends the options of dosr double_dash_ends_the_options() { as_user dosr -r e2e -t date -- /usr/bin/date >/dev/null @@ -417,4 +448,6 @@ check "the executions are logged under the name dosr" dosr_logs_under_its_name check "chsr is confined by Landlock" chsr_is_confined_by_landlock check "-- ends the options of dosr" double_dash_ends_the_options check "dosr -i shows None for a task without capabilities" info_shows_no_capabilities_as_none +check "a task that changes the user gives the groups of the target user" groups_of_the_target_user +check "from: caller keeps the groups of the caller" groups_of_the_caller_with_from exit "$FAILED" From 68ea6672abdd24bf9c371ba7c8deb75d966e34ad Mon Sep 17 00:00:00 2001 From: LeChatP Date: Sun, 25 Oct 2026 19:39:00 +0100 Subject: [PATCH 61/88] fix: a task whose user or groups do not exist is left out instead of run with what is left * fix: a setuid user that nobody has (a typo, a removed account) was dropped, and the command ran as the caller with the capabilities of the task; a setgid group that does not exist was removed from the list, and with none left, the command kept the primary group of the caller * fix: the credentials of a task are resolved before it becomes the best task; a user, a group or an empty list of groups that does not resolve leaves the task out, like a task that does not match, so that it tells nothing more before the authentication, and the log names the task and what is missing * tests: the two most precise tasks, one with a missing user and one with a missing group, lose to a task that accepts any arguments, and without it none is chosen; the test fails with the former conversion Co-Authored-By: Claude --- src/dosr/finder/mod.rs | 106 ++++++++++++++++++++++++++++++++++++++--- 1 file changed, 100 insertions(+), 6 deletions(-) diff --git a/src/dosr/finder/mod.rs b/src/dosr/finder/mod.rs index 87d7d101..dd2d2970 100644 --- a/src/dosr/finder/mod.rs +++ b/src/dosr/finder/mod.rs @@ -36,7 +36,10 @@ use serde::de::DeserializeSeed; use crate::{ Cli, error::{SrError, SrResult}, - finder::de::{cred::CredOwnedData, settings::read_storage}, + finder::de::{ + cred::{CredData, CredOwnedData}, + settings::read_storage, + }, }; pub mod api; @@ -230,6 +233,34 @@ where .and_then(BestExecSettings::with_target_groups) } +/// The user and the groups of the credentials of a task, or what does not resolve: a name that +/// nobody has, an id without an entry, or a lookup that fails. Before, such a user was dropped, +/// so the command ran as the caller, and such a group was dropped from the list, so the command +/// kept the primary group of the caller when no group was left. +fn resolve_cred(cred: &CredData<'_>) -> Result<(Option, Option>), String> { + let setuid = cred + .setuid + .as_ref() + .map(|user| user.fetch_user().ok_or_else(|| format!("the user {user}"))) + .transpose()?; + let group = |group: &DGroupType<'_>| { + group + .fetch_group() + .ok_or_else(|| format!("the group {group}")) + }; + let setgroups = match &cred.setgroups { + None => None, + Some(DGroups::Single(single)) => Some(vec![group(single)?]), + Some(DGroups::Multiple(groups)) if groups.is_empty() => { + return Err("an empty list of groups".into()); + } + Some(DGroups::Multiple(groups)) => { + Some(groups.iter().map(group).collect::>()?) + } + }; + Ok((setuid, setgroups)) +} + /// The groups of `user` as a login gives them (`initgroups(3)`): its primary group first, then /// the groups that list it as a member, like sudo-rs `User::from_libc` (sudo-rs/src/system/mod.rs). /// A gid without a name is named by its number. @@ -562,6 +593,19 @@ impl BestExecSettings { && let Some(final_path) = task_final_path && score.better_fully(&self.score) { + // a task whose user or groups do not exist cannot run as written: it is left out like + // a task that does not match, so that it tells nothing more before the authentication + let (setuid, setgroups) = match resolve_cred(&data.cred) { + Ok(cred) => cred, + Err(missing) => { + error!( + "The task {}/{} cannot run: {missing} does not exist or cannot be read", + data.role().role().role, + data.id + ); + return Ok(found); + } + }; debug!("found better task settings"); self.best_task = Some(task_address); // a tie with the previous best task does not matter anymore @@ -571,11 +615,8 @@ impl BestExecSettings { self.task = Some(data.id.to_string()); self.env_path = task_env_path.unwrap_or_else(|| temp_opt_stack.calc_path(env_path)); self.score = score; - self.cred.setuid = data.cred.setuid.clone().and_then(|u| u.fetch_user()); - self.cred.setgroups = data.cred.setgroups.clone().map(|g| match g { - DGroups::Single(g) => vec![g.fetch_group()].into_iter().flatten().collect(), - DGroups::Multiple(g) => g.iter().filter_map(DGroupType::fetch_group).collect(), - }); + self.cred.setuid = setuid; + self.cred.setgroups = setgroups; self.cred.groups_from = data.cred.groups_from; self.cred.caps = data.cred.caps; opt_stack.set_role(data); @@ -1261,6 +1302,59 @@ mod tests { .map(|settings| settings.task.unwrap_or_default()) } + #[test] + fn test_a_task_whose_credentials_do_not_resolve_is_left_out() { + let task = |name: &str, cred: Value| serde_json::json!({"name": name, "cred": cred, "commands": ["/usr/bin/ls -l"]}); + let role = |tasks: Vec| { + serde_json::json!([{ + "name": "r", + "actors": [{"type": "user", "id": nix::unistd::getuid().as_raw()}], + "tasks": tasks + }]) + }; + let decide = |roles: Value| { + let dir = PolicyDir::new("unresolved"); + let root = dir.0.join("rootasrole"); + write_file( + &root, + &root_settings(StorageMethod::JSON, &root, roles), + StorageMethod::JSON, + 0o600, + ); + let cli = Cli::builder() + .cmd_path("/usr/bin/ls") + .cmd_args(vec!["-l".to_string()]) + .build(); + find_best_exec_settings( + &cli, + &dummy_cred(), + &root, + &root, + StorageMethod::JSON, + vec![("A", "B")], + &["/usr/bin"], + ) + .unwrap() + }; + let broken = || { + vec![ + task("no-user", serde_json::json!({"setuid": "rar-no-such-user"})), + task( + "no-group", + serde_json::json!({"setgid": ["root", "rar-no-such-group"]}), + ), + ] + }; + // the broken tasks are more precise than any, which is chosen + let mut tasks = broken(); + tasks.push(serde_json::json!({"name": "any", "commands": ["/usr/bin/ls ^.*$"]})); + assert_eq!(decide(role(tasks)).task.as_deref(), Some("any")); + // without another task, none is chosen, and the command is refused after authentication + let settings = decide(role(broken())); + assert_eq!(settings.task, None); + assert!(!settings.score.fully_matching()); + } + #[test] fn test_find_best_exec_settings_single_file() { for method in [StorageMethod::JSON, StorageMethod::CBOR] { From b2cf2391ed14f62919c35ec7ae61197b94df6db4 Mon Sep 17 00:00:00 2001 From: LeChatP Date: Sun, 25 Oct 2026 19:39:01 +0100 Subject: [PATCH 62/88] tests: end-to-end check of a task whose group does not exist * tests: dosr refuses the task, and its log names the task and the missing group Co-Authored-By: Claude --- tests/vagrant/e2e.sh | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tests/vagrant/e2e.sh b/tests/vagrant/e2e.sh index fc7d5649..8ecd7c25 100755 --- a/tests/vagrant/e2e.sh +++ b/tests/vagrant/e2e.sh @@ -122,6 +122,11 @@ setup() { "cred": { "setuid": "nobody", "setgid": { "from": "caller" } }, "commands": ["/usr/bin/id ^-G$"] }, + { + "name": "missing-group", + "cred": { "setgid": "rar-e2e-missing" }, + "commands": ["/usr/bin/whoami"] + }, { "name": "password", "options": { @@ -417,6 +422,22 @@ groups_of_the_caller_with_from() { [ "$got" = "$(as_user id -G | sorted_groups)" ] } +# a task whose group does not exist is left out, and the log says why: the group was dropped, so +# the command ran with the primary group of the caller +missing_group_leaves_the_task_out() { + local since + since=$(date '+%Y-%m-%d %H:%M:%S') + sleep 1 + as_user dosr -r e2e -t missing-group /usr/bin/whoami >/dev/null 2>&1 && return 1 + for _ in 1 2 3 4 5; do + journalctl -t dosr --since "$since" -o cat | + grep -q 'e2e/missing-group cannot run: the group rar-e2e-missing does not exist' && + return 0 + sleep 1 + done + return 1 +} + # -- ends the options of dosr double_dash_ends_the_options() { as_user dosr -r e2e -t date -- /usr/bin/date >/dev/null @@ -450,4 +471,5 @@ check "-- ends the options of dosr" double_dash_ends_the_options check "dosr -i shows None for a task without capabilities" info_shows_no_capabilities_as_none check "a task that changes the user gives the groups of the target user" groups_of_the_target_user check "from: caller keeps the groups of the caller" groups_of_the_caller_with_from +check "a task whose group does not exist is left out" missing_group_leaves_the_task_out exit "$FAILED" From bfb4c0535915665a0e6effe7f6dfcdb61ecba1ba Mon Sep 17 00:00:00 2001 From: LeChatP Date: Sun, 25 Oct 2026 20:01:37 +0100 Subject: [PATCH 63/88] fix: a regular expression on the arguments applies to the whole arguments, whatever its alternations * fix: ^start|stop$ is ^start or stop$ for the regex crate, so a rule allowed any arguments starting with start or ending with stop; dosr now compiles ^(?:body)$, so that it allows start or stop alone; a group, a character class and an escaped bar keep their meaning, and an escaped final dollar sign stays in the body * chore: rootasrole-core gets regex_body, anchored_regex and has_top_level_alternation in database::command, which tells a bar outside of any group and class, escapes included * feat: the downgrade to 4.1.0 writes a top-level alternation ^(?:start|stop)$, in lists and selectors and with the short keys, so that 4.1.0 allows no more than 4.2.0; the upgrade leaves the rules as written, since they now allow less (decided by the maintainer) * tests: ^start|stop$ allows start and stop, not start anything nor please stop, and the test fails without the anchoring; groups, classes and escapes; the body and its anchored form; the downgrade and the upgrade Co-Authored-By: Claude --- core/src/database/command.rs | 90 +++++++++++++++++++++++++++++++++ core/src/database/migrations.rs | 84 +++++++++++++++++++++++++++++- src/dosr/finder/cmd.rs | 30 ++++++++++- 3 files changed, 201 insertions(+), 3 deletions(-) diff --git a/core/src/database/command.rs b/core/src/database/command.rs index 77237b2a..64540af5 100644 --- a/core/src/database/command.rs +++ b/core/src/database/command.rs @@ -30,6 +30,58 @@ pub fn regex_arguments(args: &str) -> Option<&str> { (args.len() >= 2 && args.starts_with('^') && args.ends_with('$')).then_some(args) } +/// The body of the regular expression of the arguments, between its `^` and its final `$`. A +/// final `\$` is a dollar sign, not the end of the arguments, and stays in the body. +#[must_use] +pub fn regex_body(regex: &str) -> &str { + let body = regex.strip_prefix('^').unwrap_or(regex); + match body.strip_suffix('$') { + Some(stripped) if !ends_with_an_escape(stripped) => stripped, + _ => body, + } +} + +/// The regular expression of the arguments, anchored at both ends whatever its alternations. +/// +/// `^start|stop$` alone is `^start` or `stop$`, which allows any arguments that start with +/// `start` or end with `stop`. +#[must_use] +pub fn anchored_regex(regex: &str) -> String { + format!("^(?:{})$", regex_body(regex)) +} + +/// Whether `body` has a `|` outside of any group and character class. +/// +/// It is the alternation that `^…$` does not anchor on both sides. +#[must_use] +pub fn has_top_level_alternation(body: &str) -> bool { + let (mut groups, mut classes) = (0usize, 0usize); + let mut chars = body.chars().peekable(); + while let Some(c) = chars.next() { + match c { + '\\' => { + chars.next(); + } + '[' => { + classes += 1; + // a `]` right after `[` or `[^` is a bracket, not the end of the class + chars.next_if_eq(&'^'); + chars.next_if_eq(&']'); + } + ']' if classes > 0 => classes -= 1, + '(' if classes == 0 => groups += 1, + ')' if classes == 0 => groups = groups.saturating_sub(1), + '|' if classes == 0 && groups == 0 => return true, + _ => {} + } + } + false +} + +fn ends_with_an_escape(text: &str) -> bool { + text.chars().rev().take_while(|&c| c == '\\').count() % 2 == 1 +} + #[cfg(test)] mod tests { use super::*; @@ -54,6 +106,44 @@ mod tests { assert_eq!(split_command("'/usr/bin/id"), None); } + #[test] + fn test_anchored_regex() { + assert_eq!(anchored_regex("^start|stop$"), "^(?:start|stop)$"); + assert_eq!(anchored_regex("^-n [0-9]+$"), "^(?:-n [0-9]+)$"); + assert_eq!(anchored_regex("^$"), "^(?:)$"); + // an escaped dollar sign is part of the body + assert_eq!(regex_body("^price \\$"), "price \\$"); + assert_eq!(anchored_regex("^price \\$"), "^(?:price \\$)$"); + // an escaped backslash before the final $ does not escape it + assert_eq!(regex_body("^a\\\\$"), "a\\\\"); + } + + #[test] + fn test_has_top_level_alternation() { + for body in [ + "start|stop", + "(?i)start|stop", + "a(b|c)|d", + "[a-z]|x", + "\\(|x", + ] { + assert!(has_top_level_alternation(body), "{body}"); + } + for body in [ + "(start|stop)", + "(?:start|stop) now", + "[|]", + "[]|]", + "[^]|]", + "[[:alpha:]|]+", + "a\\|b", + "-n [0-9]+", + "", + ] { + assert!(!has_top_level_alternation(body), "{body}"); + } + } + #[test] fn test_regex_arguments() { assert_eq!( diff --git a/core/src/database/migrations.rs b/core/src/database/migrations.rs index f15dd4fb..803c1838 100644 --- a/core/src/database/migrations.rs +++ b/core/src/database/migrations.rs @@ -11,7 +11,12 @@ use serde_json::Value; use crate::{ PACKAGE_VERSION, - database::migration::{Migration, MigrationError}, + database::{ + command::{ + anchored_regex, has_top_level_alternation, regex_arguments, regex_body, split_command, + }, + migration::{Migration, MigrationError}, + }, util::{ENV_DELETE_LIST_SLICE, is_valid_env_pattern}, }; @@ -76,6 +81,7 @@ pub const DOCUMENT_MIGRATIONS: &[Migration] = &[ down: |_, document| { remove_password_options(document); remove_groups_from(document); + rewrite_commands(document, group_top_level_alternation); Ok(()) }, }, @@ -239,6 +245,18 @@ fn remove_cred_groups_from(cred: &mut serde_json::Map) { } } +/// A top-level alternation grouped for 4.1.0, which allows no more than 4.2.0. +/// +/// Since 4.2.0, a regular expression on the arguments matches the whole arguments whatever its +/// alternations, where 4.1.0 read `^start|stop$` as `^start` or `stop$`. The upgrade leaves it as +/// written, since it now allows less; the downgrade writes `^(?:start|stop)$`. +fn group_top_level_alternation(command: &str) -> Option { + let (path, args) = split_command(command)?; + let regex = regex_arguments(args)?; + has_top_level_alternation(regex_body(regex)) + .then(|| format!("{path} {}", anchored_regex(regex))) +} + fn path_only(command: &str) -> bool { let command = command.trim(); !command.is_empty() && !command.contains(char::is_whitespace) @@ -749,6 +767,70 @@ mod tests { assert_eq!(document["roles"][1]["t"][0]["i"], json!({})); } + #[test] + fn test_top_level_alternation_is_grouped_down() { + let mut document = json!({ + "roles": [{ + "name": "r", + "tasks": [{ + "name": "t", + "commands": [ + "/usr/bin/systemctl ^start|stop$", + "/usr/bin/systemctl '^start|restart$'", + "/usr/bin/systemctl ^(start|stop) caddy$", + "/usr/bin/tail ^-n [0-9]+$", + "/usr/bin/apt update" + ] + }, { + "name": "selector", + "commands": {"default": "none", "del": ["/usr/bin/kill ^-9|-KILL$"]} + }] + }, { + // the short keys of the CBOR format + "name": "c", + "t": [{"name": "t", "c": ["/usr/bin/systemctl ^a|b$"]}] + }] + }); + let before = document.clone(); + Migration::migrate_from( + &Version::new(4, 1, 0), + &PACKAGE_VERSION, + &mut document, + DOCUMENT_MIGRATIONS, + ) + .unwrap(); + assert_eq!( + document, before, + "the upgrade leaves the commands as written" + ); + + Migration::migrate_from( + &PACKAGE_VERSION, + &Version::new(4, 1, 0), + &mut document, + DOCUMENT_MIGRATIONS, + ) + .unwrap(); + assert_eq!( + document["roles"][0]["tasks"][0]["commands"], + json!([ + "/usr/bin/systemctl ^(?:start|stop)$", + "/usr/bin/systemctl ^(?:start|restart)$", + "/usr/bin/systemctl ^(start|stop) caddy$", + "/usr/bin/tail ^-n [0-9]+$", + "/usr/bin/apt update" + ]) + ); + assert_eq!( + document["roles"][0]["tasks"][1]["commands"]["del"], + json!(["/usr/bin/kill ^(?:-9|-KILL)$"]) + ); + assert_eq!( + document["roles"][1]["t"][0]["c"], + json!(["/usr/bin/systemctl ^(?:a|b)$"]) + ); + } + #[test] fn test_4_1_0_documents_up_and_down() { let original = json!({ diff --git a/src/dosr/finder/cmd.rs b/src/dosr/finder/cmd.rs index 15023b10..d1799a00 100644 --- a/src/dosr/finder/cmd.rs +++ b/src/dosr/finder/cmd.rs @@ -1,7 +1,7 @@ use log::{debug, info, warn}; use rootasrole_core::{ database::{ - command::{regex_arguments, split_command}, + command::{anchored_regex, regex_arguments, split_command}, score::{CmdMin, CmdOrder}, }, util::{all_paths_from_env, match_single_path}, @@ -94,7 +94,8 @@ fn match_regex_args( .order(CmdOrder::FullRegexArgs) .build()); } - evaluate_regex_cmd(regex, &shell_words::join(input_args)).inspect_err(|e| { + // anchored at both ends whatever its alternations: ^start|stop$ allows start or stop alone + evaluate_regex_cmd(&anchored_regex(regex), &shell_words::join(input_args)).inspect_err(|e| { warn!("Invalid regular expression in the policy, it never matches: {regex}: {e}"); }) } @@ -701,6 +702,31 @@ mod tests { ) } + #[test] + fn test_a_top_level_alternation_is_anchored_on_both_sides() { + let systemctl = + |rule: &str, args: &[&str]| allows(rule, "/usr/bin/systemctl", args).matching(); + let rule = "/usr/bin/systemctl ^start|stop$"; + assert!(systemctl(rule, &["start"])); + assert!(systemctl(rule, &["stop"])); + // not ^start or stop$: the alternation is between the whole arguments + assert!(!systemctl(rule, &["start", "anything"])); + assert!(!systemctl(rule, &["please", "stop"])); + // a group, a character class and an escaped bar keep their meaning + assert!(systemctl( + "/usr/bin/systemctl ^(start|stop) caddy$", + &["stop", "caddy"] + )); + assert!(!systemctl( + "/usr/bin/systemctl ^(start|stop) caddy$", + &["stop", "sshd"] + )); + // a bar in a character class is no alternation; the arguments are joined with the + // quotes of a shell, so a bar typed by the user would be '|' + assert!(systemctl("/usr/bin/systemctl ^[x|y]z$", &["xz"])); + assert!(!systemctl("/usr/bin/systemctl ^[x|y]z$", &["x"])); + } + #[test] fn test_regex_arguments_with_or_without_quotes() { for rule in [ From bde245da8050e614ba9f8ebd05a946fa464e08e0 Mon Sep 17 00:00:00 2001 From: LeChatP Date: Sun, 25 Oct 2026 20:01:37 +0100 Subject: [PATCH 64/88] tests: end-to-end check of a top-level alternation on the arguments * tests: ^one|two$ allows echo one, and refuses echo one more and echo say two Co-Authored-By: Claude --- tests/vagrant/e2e.sh | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/vagrant/e2e.sh b/tests/vagrant/e2e.sh index 8ecd7c25..ed93823f 100755 --- a/tests/vagrant/e2e.sh +++ b/tests/vagrant/e2e.sh @@ -122,6 +122,7 @@ setup() { "cred": { "setuid": "nobody", "setgid": { "from": "caller" } }, "commands": ["/usr/bin/id ^-G$"] }, + { "name": "alternation", "commands": ["/usr/bin/echo ^one|two$"] }, { "name": "missing-group", "cred": { "setgid": "rar-e2e-missing" }, @@ -438,6 +439,14 @@ missing_group_leaves_the_task_out() { return 1 } +# a top-level alternation is anchored on both sides: ^one|two$ allows one or two alone, no longer +# any arguments that start with one or end with two +alternation_is_anchored() { + [ "$(as_user dosr -r e2e -t alternation /usr/bin/echo one)" = "one" ] && + ! as_user dosr -r e2e -t alternation /usr/bin/echo one more >/dev/null 2>&1 && + ! as_user dosr -r e2e -t alternation /usr/bin/echo say two >/dev/null 2>&1 +} + # -- ends the options of dosr double_dash_ends_the_options() { as_user dosr -r e2e -t date -- /usr/bin/date >/dev/null @@ -472,4 +481,5 @@ check "dosr -i shows None for a task without capabilities" info_shows_no_capabil check "a task that changes the user gives the groups of the target user" groups_of_the_target_user check "from: caller keeps the groups of the caller" groups_of_the_caller_with_from check "a task whose group does not exist is left out" missing_group_leaves_the_task_out +check "a top-level alternation is anchored on both sides" alternation_is_anchored exit "$FAILED" From 27c1d5cac84e0aa309623d01bb6f971948e8562f Mon Sep 17 00:00:00 2001 From: LeChatP Date: Sun, 25 Oct 2026 20:32:30 +0100 Subject: [PATCH 65/88] tests: the final policy of the book is read by rootasrole-core and decides as the chapter says * tests: the policy under "The complete WebOps policy" of book/src/configuration.md is migrated and read by rootasrole-core, then dosr decides with it, the current user in place of the team except sys-admin: the logs are read with tail, less and journalctl, the path traversal and the pager of systemctl are refused, caddy-service manages caddy and nothing else, alice's escape with install is refused, web-push publishes where www-data exists, and the tasks of sys-admin are not held * tests: skipped when the book is not in the source tree (the crate and the Debian sources leave it out), so until the docs are merged into dev; checked against the current page, and the test fails when the rule of tail lets a path traversal through Co-Authored-By: Claude --- src/dosr/finder/mod.rs | 110 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 110 insertions(+) diff --git a/src/dosr/finder/mod.rs b/src/dosr/finder/mod.rs index dd2d2970..576ae2c1 100644 --- a/src/dosr/finder/mod.rs +++ b/src/dosr/finder/mod.rs @@ -1302,6 +1302,116 @@ mod tests { .map(|settings| settings.task.unwrap_or_default()) } + /// The final policy of the book, under its heading in `book/src/configuration.md`, or `None` + /// when the book is not in this source tree: the crate and the Debian sources leave it out. + fn book_policy() -> Option { + let page = std::fs::read_to_string( + Path::new(env!("CARGO_MANIFEST_DIR")).join("book/src/configuration.md"), + ) + .ok()?; + let section = &page[page.find("## The complete WebOps policy")?..]; + let start = section.find("```json\n")? + "```json\n".len(); + let end = section[start..].find("\n```")? + start; + Some(serde_json::from_str(§ion[start..end]).expect("the policy of the book is JSON")) + } + + #[test] + fn test_the_policy_of_the_book() { + let Some(mut policy) = book_policy() else { + eprintln!("skipped: the book is not in this source tree"); + return; + }; + rootasrole_core::database::migrations::migrate_document(&mut policy).unwrap(); + serde_json::from_value::(policy.clone()) + .expect("rootasrole-core reads the policy of the book"); + // the current user takes the place of the team, but not of sys-admin, which excludes + // caddy-admin + let uid = nix::unistd::getuid().as_raw(); + for role in policy["roles"].as_array_mut().unwrap() { + if role["name"] != "sys-admin" { + role["actors"] = serde_json::json!([{"type": "user", "id": uid}]); + } + } + let dir = PolicyDir::new("book"); + let file = dir.0.join("default.json"); + write_file(&file, &policy, StorageMethod::JSON, 0o600); + let root = dir.0.join("rootasrole.json"); + write_file( + &root, + &root_settings(StorageMethod::JSON, &file, serde_json::json!([])), + StorageMethod::JSON, + 0o600, + ); + let decide = |cmd: &str, args: &[&str]| -> Option { + let cli = Cli::builder() + .cmd_path(cmd) + .cmd_args(args.iter().map(ToString::to_string)) + .build(); + find_best_exec_settings( + &cli, + &dummy_cred(), + &root, + &root, + StorageMethod::JSON, + vec![("A", "B")], + &["/usr/bin"], + ) + .ok() + .filter(|settings| settings.score.fully_matching()) + .and_then(|settings| settings.task) + }; + let log = "/var/log/caddy/access.log"; + assert_eq!( + decide("/usr/bin/tail", &["-n", "50", log]).as_deref(), + Some("read-logs") + ); + assert_eq!( + decide("/usr/bin/tail", &["-f", log]).as_deref(), + Some("read-logs") + ); + assert_eq!( + decide("/usr/bin/journalctl", &["--no-pager", "-u", "caddy"]).as_deref(), + Some("read-logs") + ); + // the path traversal of the chapter + let traversal = "/var/log/caddy/../../../etc/shadow"; + assert_eq!(decide("/usr/bin/tail", &["-n", "50", traversal]), None); + assert_eq!(decide("/usr/bin/less", &[traversal]), None); + // the pager of systemctl, without --no-pager + assert_eq!(decide("/usr/bin/systemctl", &["status", "caddy"]), None); + assert_eq!( + decide("/usr/bin/systemctl", &["status", "--no-pager", "caddy"]).as_deref(), + Some("caddy-service") + ); + assert_eq!( + decide("/usr/bin/systemctl", &["restart", "caddy"]).as_deref(), + Some("caddy-service") + ); + assert_eq!(decide("/usr/bin/systemctl", &["restart", "sshd"]), None); + // alice's escape: more arguments than the regular expression allows + let escape = [ + "-d", + "-m", + "777", + "-o", + "alice", + "-g", + "alice", + "/srv/www/x", + "/etc", + ]; + assert_eq!(decide("/usr/bin/install", &escape), None); + // the user of web-push is www-data, which not every distribution has + if User::from_name("www-data").ok().flatten().is_some() { + assert_eq!( + decide("/usr/bin/install", &["-d", "-m", "755", "/srv/www/site"]).as_deref(), + Some("web-push") + ); + } + // the root-equivalent tasks of sys-admin are not held + assert_eq!(decide("/usr/bin/systemctl", &["reboot"]), None); + } + #[test] fn test_a_task_whose_credentials_do_not_resolve_is_left_out() { let task = |name: &str, cred: Value| serde_json::json!({"name": name, "cred": cred, "commands": ["/usr/bin/ls -l"]}); From dd9ac9268c383fbca0599a1a0ba30e994b66567e Mon Sep 17 00:00:00 2001 From: LeChatP Date: Sun, 25 Oct 2026 21:02:00 +0100 Subject: [PATCH 66/88] =?UTF-8?q?fix:=20the=20PAM=20services=20that=20carg?= =?UTF-8?q?o=20xtask=20install=20deploys=20end=20with=20a=20newline=20*=20?= =?UTF-8?q?fix:=20the=20services=20of=20Red=20Hat,=20openSUSE=20and=20Arch?= =?UTF-8?q?=20had=20no=20final=20newline,=20so=20a=20line=20appended=20to?= =?UTF-8?q?=20/etc/pam.d/dosr=20was=20glued=20to=20the=20last=20one=20(ses?= =?UTF-8?q?sion=20include=20system-authsession=20optional=20pam=5Fenv.so?= =?UTF-8?q?=20=E2=80=A6):=20the=20line=20was=20lost,=20and=20the=20include?= =?UTF-8?q?=20broken;=20found=20by=20the=20e2e=20check=20of=20pam=5Fenv=20?= =?UTF-8?q?on=20Arch=20*=20tests:=20every=20PAM=20service=20built=20into?= =?UTF-8?q?=20xtask=20ends=20with=20a=20newline?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude --- resources/arch/arch_sr_pam.conf | 2 +- resources/opensuse/opensuse.conf | 2 +- resources/rh/rh_sr_pam.conf | 2 +- xtask/src/configure.rs | 8 ++++++++ 4 files changed, 11 insertions(+), 3 deletions(-) diff --git a/resources/arch/arch_sr_pam.conf b/resources/arch/arch_sr_pam.conf index 1b80fa27..98707ef8 100644 --- a/resources/arch/arch_sr_pam.conf +++ b/resources/arch/arch_sr_pam.conf @@ -1,4 +1,4 @@ #%PAM-1.0 auth include system-auth account include system-auth -session include system-auth \ No newline at end of file +session include system-auth diff --git a/resources/opensuse/opensuse.conf b/resources/opensuse/opensuse.conf index b5ebcb11..bc1e317f 100644 --- a/resources/opensuse/opensuse.conf +++ b/resources/opensuse/opensuse.conf @@ -3,4 +3,4 @@ auth include common-auth account include common-account password include common-password session optional pam_keyinit.so revoke -session include common-session-nonlogin \ No newline at end of file +session include common-session-nonlogin diff --git a/resources/rh/rh_sr_pam.conf b/resources/rh/rh_sr_pam.conf index 1f03d24b..fcb7a466 100644 --- a/resources/rh/rh_sr_pam.conf +++ b/resources/rh/rh_sr_pam.conf @@ -2,4 +2,4 @@ auth include system-auth account include system-auth password include system-auth -session include system-auth \ No newline at end of file +session include system-auth diff --git a/xtask/src/configure.rs b/xtask/src/configure.rs index 394f6474..65defc42 100644 --- a/xtask/src/configure.rs +++ b/xtask/src/configure.rs @@ -459,6 +459,14 @@ fn upgrade_policy() { mod tests { use super::*; + #[test] + fn test_pam_services_end_with_a_newline() { + // a line appended to /etc/pam.d/dosr would otherwise be glued to the last one + for config in PAM_CONFIGS { + assert!(config.ends_with('\n'), "{config:?}"); + } + } + #[test] fn test_default_policy_file_of_a_policy_directory() { assert_eq!( From 4e2ce865b2fec468cebf01e3131f9f153967c19b Mon Sep 17 00:00:00 2001 From: LeChatP Date: Sun, 25 Oct 2026 21:02:01 +0100 Subject: [PATCH 67/88] tests: the Arch machine of tests/vagrant uses the generic/arch box * tests: archlinux/archlinux publishes no box for libvirt anymore; generic/arch does, and the Vagrantfile says how to upgrade it the first time, since vagrant installs rsync without upgrading the system and breaks it Co-Authored-By: Claude --- tests/vagrant/Vagrantfile | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/tests/vagrant/Vagrantfile b/tests/vagrant/Vagrantfile index 275dbff3..56c8b059 100644 --- a/tests/vagrant/Vagrantfile +++ b/tests/vagrant/Vagrantfile @@ -12,11 +12,16 @@ # vagrant destroy -f # # Machines: debian (the distribution of the official package) and arch (the AUR package dosr). -# Their boxes can be replaced with RAR_BOX_DEBIAN and RAR_BOX_ARCH. +# Their boxes can be replaced with RAR_BOX_DEBIAN and RAR_BOX_ARCH. archlinux/archlinux publishes +# no box for libvirt anymore, so arch uses generic/arch, which is older than the repositories: the +# first `vagrant up arch` installs rsync without upgrading the system, which breaks rsync. Then: +# +# vagrant ssh arch -c 'sudo pacman -Sy --noconfirm archlinux-keyring && sudo pacman -Syu --noconfirm' +# vagrant reload arch --provision BOXES = { "debian" => ENV.fetch("RAR_BOX_DEBIAN", "debian/testing64"), - "arch" => ENV.fetch("RAR_BOX_ARCH", "archlinux/archlinux"), + "arch" => ENV.fetch("RAR_BOX_ARCH", "generic/arch"), }.freeze Vagrant.configure("2") do |config| From 0c67f5e8cd1a1fa565587701a98660ec417386be Mon Sep 17 00:00:00 2001 From: LeChatP Date: Sun, 25 Oct 2026 21:51:22 +0100 Subject: [PATCH 68/88] feat: the tty option says who owns the terminal of the command, the caller by default * feat: "options": {"tty": {"owner": "caller"|"target"}} at every level, inherited like the others; caller, the default, keeps the behaviour of 4.1.0: the terminal stays with the user who runs dosr, so the processes of the target account cannot open it, but a program that reopens its terminal by its name (pinentry through GPG_TTY) fails; target gives it to the user that the command runs as, like sudo and sudo-rs since 0.2.11, at the cost of letting the other processes of that account open it while the command runs (#51) * feat: with target, and only for a command run as another user, dosr changes the owner of the pty with fchown on its descriptor, keeping its group and mode, with CAP_CHOWN raised for that call only; sudo-rs changes it by its path (src/exec/use_pty/parent.rs, get_pty); a failure stops before the command starts * feat: rootasrole-exec gets PtyFollower::set_owner and runner::run_with_pty_setup, which lets the caller prepare the pty before the command starts; run and run_with_pty keep their signature; a failed setup gives the caller back its signal mask * feat: the downgrade to 4.1.0 removes the tty option, which 4.1.0 does not know: the terminal stays with the caller * fix: a doc comment of path_only in the migrations was above another function * tests: the option's format, CBOR round trip, default and inheritance, its migrations, the owner chosen by dosr, set_owner, and a failed setup that stops before the command and restores the signal mask Co-Authored-By: Claude --- core/src/database/migrations.rs | 59 ++++++++++++--- core/src/database/options.rs | 126 ++++++++++++++++++++++++++++++++ exec/src/pty.rs | 41 +++++++++++ exec/src/runner.rs | 80 +++++++++++++++++++- src/dosr/finder/mod.rs | 40 +++++++++- src/dosr/finder/options.rs | 36 ++++++++- src/dosr/lib.rs | 54 +++++++++++++- 7 files changed, 419 insertions(+), 17 deletions(-) diff --git a/core/src/database/migrations.rs b/core/src/database/migrations.rs index 803c1838..4d3b8c79 100644 --- a/core/src/database/migrations.rs +++ b/core/src/database/migrations.rs @@ -79,7 +79,7 @@ pub const DOCUMENT_MIGRATIONS: &[Migration] = &[ to: || Version::new(4, 2, 0), up: unchanged, down: |_, document| { - remove_password_options(document); + remove_options_of_4_2(document); remove_groups_from(document); rewrite_commands(document, group_top_level_alternation); Ok(()) @@ -181,19 +181,21 @@ fn rewrite_path_behaviors(document: &mut Value, rewrite: fn(&str) -> Option<&'st } } -/// Whether a command of the policy is only a path, without arguments. -/// 4.1.0 has no `password` option: it is removed from the options of every level. -fn remove_password_options(document: &mut Value) { +/// 4.1.0 has neither the `password` nor the `tty` option: they are removed from the options of +/// every level. Without `tty`, 4.1.0 leaves the terminal to the caller, as `"owner": "caller"`. +fn remove_options_of_4_2(document: &mut Value) { match document { Value::Object(map) => { - if let Some(Value::Object(options)) = map.get_mut("options") - && options.remove("password").is_some() - { - info!("Removing the password option, unknown before 4.2.0"); + if let Some(Value::Object(options)) = map.get_mut("options") { + for name in ["password", "tty"] { + if options.remove(name).is_some() { + info!("Removing the {name} option, unknown before 4.2.0"); + } + } } - map.values_mut().for_each(remove_password_options); + map.values_mut().for_each(remove_options_of_4_2); } - Value::Array(values) => values.iter_mut().for_each(remove_password_options), + Value::Array(values) => values.iter_mut().for_each(remove_options_of_4_2), _ => {} } } @@ -257,6 +259,7 @@ fn group_top_level_alternation(command: &str) -> Option { .then(|| format!("{path} {}", anchored_regex(regex))) } +/// Whether a command of the policy is only a path, without arguments. fn path_only(command: &str) -> bool { let command = command.trim(); !command.is_empty() && !command.contains(char::is_whitespace) @@ -710,6 +713,42 @@ mod tests { assert_eq!(document["roles"][0]["tasks"][0]["options"], json!({})); } + #[test] + fn test_tty_option_up_and_down() { + let mut document = json!({ + "options": {"tty": {"owner": "target"}, "timeout": {"type": "tty"}}, + "roles": [{ + "name": "r", + "options": {"tty": {"owner": "caller"}}, + "tasks": [{"name": "t", "options": {"tty": {"owner": "target"}, "umask": "022"}}] + }] + }); + let before = document.clone(); + Migration::migrate_from( + &Version::new(4, 1, 0), + &PACKAGE_VERSION, + &mut document, + DOCUMENT_MIGRATIONS, + ) + .unwrap(); + assert_eq!(document, before, "the upgrade keeps the option"); + + // 4.1.0 always leaves the terminal to the caller + Migration::migrate_from( + &PACKAGE_VERSION, + &Version::new(4, 1, 0), + &mut document, + DOCUMENT_MIGRATIONS, + ) + .unwrap(); + assert_eq!(document["options"], json!({"timeout": {"type": "tty"}})); + assert_eq!(document["roles"][0]["options"], json!({})); + assert_eq!( + document["roles"][0]["tasks"][0]["options"], + json!({"umask": "022"}) + ); + } + #[test] fn test_groups_from_is_removed_down() { let task = |cred: Value| json!({"name": "t", "cred": cred}); diff --git a/core/src/database/options.rs b/core/src/database/options.rs index b3d7f2d0..830d73d9 100644 --- a/core/src/database/options.rs +++ b/core/src/database/options.rs @@ -130,6 +130,46 @@ pub struct SPassword { pub extra_fields: Map, } +/// Who owns the pseudo-terminal that dosr gives to the command. +#[derive(Serialize, Deserialize, PartialEq, Eq, Debug, Clone, Copy, Default)] +#[serde(rename_all = "kebab-case")] +#[repr(u32)] +pub enum TtyOwner { + /// The user who runs dosr. The processes of the user that the command runs as cannot open + /// the terminal by its name: a program that does, such as pinentry through `GPG_TTY`, fails. + #[default] + Caller = HARDENED_ENUM_VALUE_0, + /// The user that the command runs as, like sudo. Its other processes can then open the + /// terminal while the command runs, and read what is typed into it. + Target = HARDENED_ENUM_VALUE_1, +} + +impl FromStr for TtyOwner { + type Err = String; + + fn from_str(s: &str) -> Result { + match s { + "caller" => Ok(Self::Caller), + "target" => Ok(Self::Target), + _ => Err(format!( + "The owner of the terminal is caller or target: {s}" + )), + } + } +} + +/// The pseudo-terminal of the command, when dosr runs from a terminal. +/// +/// A level that does not set `owner` inherits it from the upper levels, down to `caller`. +#[derive(Serialize, Deserialize, PartialEq, Eq, Debug, Clone, Default, Builder)] +pub struct STty { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub owner: Option, + #[serde(default, flatten, skip_serializing_if = "Map::is_empty")] + #[builder(default)] + pub extra_fields: Map, +} + #[derive(Serialize, Deserialize, PartialEq, Eq, Debug, Clone, Default, Builder)] pub struct STimeout { #[serde(default, rename = "type", skip_serializing_if = "Option::is_none")] @@ -615,6 +655,8 @@ pub struct Opt { #[serde(default, skip_serializing_if = "Option::is_none")] pub password: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + pub tty: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub umask: Option, #[serde(default, flatten)] pub extra_fields: Map, @@ -634,6 +676,7 @@ impl Opt { workdir: Option, timeout: Option, password: Option, + tty: Option, umask: Option, #[builder(default)] extra_fields: Map, ) -> Self { @@ -648,6 +691,7 @@ impl Opt { workdir, timeout, password, + tty, umask, extra_fields, } @@ -691,6 +735,7 @@ impl Opt { .delay(PASSWORD_DELAY) .build(), ) + .tty(STty::builder().owner(TtyOwner::default()).build()) .build() } } @@ -1154,6 +1199,18 @@ impl OptStack { ) .build(), ) + .tty( + STty::builder() + .maybe_owner( + self.find_in_options(|opt| opt + .tty + .as_ref() + .and_then(|tty| tty.owner) + .map(|owner| (opt.level, owner))) + .map(|(_, owner)| owner) + ) + .build(), + ) .build() )) } @@ -1984,4 +2041,73 @@ mod tests { assert_eq!(crate::util::PASSWORD_TRIES.get(), 3); assert_eq!(crate::util::PASSWORD_DELAY, SignedDuration::from_secs(5)); } + + #[test] + fn test_tty_option() { + let tty: STty = serde_json::from_str(r#"{"owner": "target"}"#).unwrap(); + assert_eq!(tty.owner, Some(TtyOwner::Target)); + assert_eq!( + serde_json::to_value(&tty).unwrap(), + serde_json::json!({"owner": "target"}) + ); + let tty: STty = serde_json::from_str(r#"{"owner": "caller", "future": 1}"#).unwrap(); + assert_eq!(tty.owner, Some(TtyOwner::Caller)); + assert_eq!(tty.extra_fields.len(), 1); + for invalid in [ + r#"{"owner": "root"}"#, + r#"{"owner": "Target"}"#, + r#"{"owner": 1}"#, + ] { + assert!( + serde_json::from_str::(invalid).is_err(), + "{invalid} is refused" + ); + } + let cbor = cbor4ii::serde::to_vec(Vec::new(), &tty).unwrap(); + let from_cbor: STty = cbor4ii::serde::from_slice(&cbor).unwrap(); + assert_eq!(from_cbor, tty); + assert_eq!("target".parse(), Ok(TtyOwner::Target)); + assert_eq!("caller".parse(), Ok(TtyOwner::Caller)); + assert!("unset".parse::().is_err()); + } + + // the terminal stays with the caller unless the policy says otherwise + #[test] + fn test_tty_owner_default_and_inheritance() { + assert_eq!( + Opt::level_default().tty.unwrap().owner, + Some(TtyOwner::Caller) + ); + let config = SPolicy::builder() + .role( + SRole::builder("test") + .task(STask::builder(1).build()) + .task( + STask::builder(2) + .options(|opt| { + opt.tty(STty::builder().owner(TtyOwner::Caller).build()) + .build() + }) + .build(), + ) + .build(), + ) + .options(|opt| { + opt.tty(STty::builder().owner(TtyOwner::Target).build()) + .build() + }) + .build(); + let owner = |task| { + OptStack::from_task(&config.task("test", task).unwrap()) + .to_opt() + .unwrap() + .as_ref() + .borrow() + .tty + .as_ref() + .and_then(|tty| tty.owner) + }; + assert_eq!(owner(1), Some(TtyOwner::Target)); + assert_eq!(owner(2), Some(TtyOwner::Caller)); + } } diff --git a/exec/src/pty.rs b/exec/src/pty.rs index 119aca91..9686236f 100644 --- a/exec/src/pty.rs +++ b/exec/src/pty.rs @@ -139,6 +139,19 @@ impl PtyFollower { pub fn try_clone(&self) -> io::Result { self.file.try_clone().map(|file| Self { file }) } + + /// Give the terminal to `uid`, keeping its group (`tty` when devpts is mounted with + /// `gid=5`) and its mode. Unlike sudo-rs, which changes it by its path + /// (`src/exec/use_pty/parent.rs`, `get_pty`), it is changed through the open descriptor. + /// + /// # Errors + /// Returns an error if the caller may not change the owner: it needs `CAP_CHOWN`, unless + /// `uid` already owns the terminal. + pub fn set_owner(&self, uid: libc::uid_t) -> io::Result<()> { + // a gid of -1 keeps the group + // SAFETY: fchown does not access memory, the descriptor is owned by `self.file` + cerr(unsafe { libc::fchown(self.file.as_raw_fd(), uid, libc::gid_t::MAX) }).map(|_| ()) + } } impl AsFd for PtyFollower { @@ -163,6 +176,7 @@ mod tests { use std::fs::File; use std::io::{Read, Write}; use std::os::fd::{AsFd, AsRawFd, FromRawFd}; + use std::os::unix::ffi::OsStrExt; #[test] fn test_pty_open_basic() { @@ -175,6 +189,33 @@ mod tests { assert!(terminal::safe_isatty(&pty.follower)); } + #[test] + fn test_pty_set_owner() { + use std::os::unix::fs::MetadataExt; + + let pty = Pty::open().expect("Failed to open PTY"); + let path = std::ffi::OsStr::from_bytes(pty.path.as_bytes()); + let before = std::fs::metadata(path).expect("the pty has a path"); + // the owner it already has needs no privilege + pty.follower + .set_owner(before.uid()) + .expect("the owner of the pty may give it to itself"); + let after = std::fs::metadata(path).expect("the pty has a path"); + assert_eq!( + (after.uid(), after.gid(), after.mode()), + (before.uid(), before.gid(), before.mode()), + "the group and the mode are kept" + ); + // SAFETY: geteuid does not access memory + if unsafe { libc::geteuid() } != 0 { + let err = pty + .follower + .set_owner(before.uid() + 1) + .expect_err("another owner needs CAP_CHOWN"); + assert_eq!(err.raw_os_error(), Some(libc::EPERM)); + } + } + #[test] fn test_pty_follower_clone() { let pty = Pty::open().expect("Failed to open PTY"); diff --git a/exec/src/runner.rs b/exec/src/runner.rs index 402ae7a7..a8aa1b7e 100644 --- a/exec/src/runner.rs +++ b/exec/src/runner.rs @@ -491,9 +491,23 @@ pub fn run( command: Command, orchestrator: Orchestrator, logger: Option>, +) -> io::Result { + run_with_pty_setup(command, orchestrator, logger, |_| Ok(())) +} + +/// Like [`run`], and when the command gets a pty, `pty_setup` prepares it before the command +/// starts, for example to give it to the user that the command runs as. +/// +/// # Errors +/// Returns the errors of [`run`], and the error of `pty_setup`, before the command starts. +pub fn run_with_pty_setup( + command: Command, + orchestrator: Orchestrator, + logger: Option>, + pty_setup: impl FnOnce(&Pty) -> io::Result<()>, ) -> io::Result { match UserTerm::open() { - Ok(term) => run_with_pty(command, orchestrator, logger, term), + Ok(term) => run_in_pty(command, orchestrator, logger, term, pty_setup), Err(_) => run_no_pty(command, orchestrator), } } @@ -520,12 +534,22 @@ pub fn run( /// - Failure in the backchannel communication /// - Failure in the orchestrator's pre-exec function /// - Failure in spawning the command execution -#[allow(clippy::too_many_lines)] pub fn run_with_pty( + command: Command, + orchestrator: Orchestrator, + logger: Option>, + user_term: UserTerm, +) -> io::Result { + run_in_pty(command, orchestrator, logger, user_term, |_| Ok(())) +} + +#[allow(clippy::too_many_lines)] +fn run_in_pty( command: Command, orchestrator: Orchestrator, logger: Option>, mut user_term: UserTerm, + pty_setup: impl FnOnce(&Pty) -> io::Result<()>, ) -> io::Result { let original_set = block_all_signals(); @@ -548,6 +572,11 @@ pub fn run_with_pty( // Create Pty let pty = Pty::open()?; + if let Err(e) = pty_setup(&pty) { + // the caller goes on without the command: its signals are delivered again + restore_signals(original_set); + return Err(e); + } let tty_size = user_term.get_size().unwrap_or_else(|_| TermSize::new(0, 0)); pty.leader.set_size(&tty_size)?; // set window size by default, resizing comes with features debug!("runner(pty): initial tty size {tty_size}"); @@ -812,7 +841,7 @@ fn restore_signals(original: Option) { #[allow(clippy::undocumented_unsafe_blocks)] #[cfg(test)] mod tests { - use super::{SimpleFileLogger, run_no_pty}; + use super::{SimpleFileLogger, run_no_pty, run_with_pty_setup}; use crate::MUTEX; use crate::orchestrator::{Orchestrator, PreExecContext, PreExecStep, Stage}; use crate::pipe::io_logger_sealed::Sealed; @@ -976,6 +1005,51 @@ mod tests { assert!(status.success()); } + /// Whether this thread blocks each of the standard signals. + fn signal_mask() -> Vec { + let mut set: libc::sigset_t = unsafe { std::mem::zeroed() }; + assert_eq!( + unsafe { libc::pthread_sigmask(libc::SIG_BLOCK, std::ptr::null(), &raw mut set) }, + 0 + ); + (1..libc::SIGRTMIN()) + .map(|signal| unsafe { libc::sigismember(&raw const set, signal) } == 1) + .collect() + } + + // with a terminal, a failed setup of the pty stops before the command starts; without one, + // there is no pty to set up and the command runs + #[test] + fn run_with_pty_setup_stops_on_a_failed_setup() { + let _guard = MUTEX.lock().unwrap(); + let dir = std::env::temp_dir().join(format!("rar-pty-setup-{}", std::process::id())); + std::fs::create_dir_all(&dir).expect("create the test directory"); + let marker = dir.join("ran"); + let mut command = Command::new("/usr/bin/touch"); + command.arg(&marker); + let mask_before = signal_mask(); + let mut called = false; + let result = run_with_pty_setup(command, Orchestrator::new(NOOP_STEPS), None, |pty| { + called = true; + assert!(!pty.path.as_bytes().is_empty()); + Err(io::Error::other("setup refused")) + }); + if called { + let error = result.expect_err("the setup error is returned"); + assert_eq!(error.to_string(), "setup refused"); + assert!(!marker.exists(), "the command did not start"); + assert_eq!( + signal_mask(), + mask_before, + "the signals are delivered again" + ); + } else { + assert!(result.expect("run without a pty").success()); + assert!(marker.exists(), "the command ran without a pty"); + } + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn run_no_pty_propagates_pre_exec_failure() { let _guard = MUTEX.lock().unwrap(); diff --git a/src/dosr/finder/mod.rs b/src/dosr/finder/mod.rs index 576ae2c1..7b471b0f 100644 --- a/src/dosr/finder/mod.rs +++ b/src/dosr/finder/mod.rs @@ -24,7 +24,7 @@ use rootasrole_core::{ database::{ actor::{DGroupType, DGroups}, env::EnvFilter, - options::{SAuthentication, SBounding, SPrivileged, STimeout, SUMask}, + options::{SAuthentication, SBounding, SPrivileged, STimeout, SUMask, TtyOwner}, score::{ActorMatchMin, CmdMin, CmdOrder, HardenedBool, Score, hardened_bool_from_bool}, structs::GroupsFrom, workdir::ResolvedWorkdir, @@ -95,6 +95,9 @@ pub struct BestExecSettings { /// How many times the password is asked, and the delay after a wrong one pub password: PasswordSettings, #[builder(default)] + /// Who owns the pseudo-terminal of the command + pub tty_owner: TtyOwner, + #[builder(default)] /// Is root id has it's privileges or not? If not, root is going to be a simple user pub root: SPrivileged, #[builder(default)] @@ -416,6 +419,7 @@ impl BestExecSettings { result.bounding = opt_stack.calc_bounding(); result.timeout = opt_stack.calc_timeout(); result.password = opt_stack.calc_password(); + result.tty_owner = opt_stack.calc_tty_owner(); result.root = opt_stack.calc_privileged(); result.umask = opt_stack.calc_umask(); result.workdir = opt_stack.calc_workdir(); @@ -1480,6 +1484,40 @@ mod tests { } } + // the tty option of the policy reaches the settings of the execution, the caller by default + #[test] + fn test_find_best_exec_settings_tty_owner() { + for method in [StorageMethod::JSON, StorageMethod::CBOR] { + let dir = PolicyDir::new(&format!("tty-{method}")); + let root = dir.0.join("rootasrole"); + let mut roles = user_role("t", "/usr/bin/ls", &[]); + let tty_owner = |root: &Path| { + let cli = Cli::builder().cmd_path("/usr/bin/ls".to_string()).build(); + find_best_exec_settings( + &cli, + &dummy_cred(), + root, + root, + method, + vec![("A", "B")], + &["/usr/bin"], + ) + .unwrap() + .tty_owner + }; + write_file( + &root, + &root_settings(method, &root, roles.clone()), + method, + 0o600, + ); + assert_eq!(tty_owner(&root), TtyOwner::Caller, "{method}"); + roles[0]["tasks"][0]["options"] = serde_json::json!({"tty": {"owner": "target"}}); + write_file(&root, &root_settings(method, &root, roles), method, 0o600); + assert_eq!(tty_owner(&root), TtyOwner::Target, "{method}"); + } + } + #[test] fn test_immutable_policy_refuses_a_mutable_file() { // a file added by someone who could not set the immutable flag (uid 0 without diff --git a/src/dosr/finder/options.rs b/src/dosr/finder/options.rs index 9dd3e6e2..76452157 100644 --- a/src/dosr/finder/options.rs +++ b/src/dosr/finder/options.rs @@ -8,7 +8,7 @@ use rootasrole_core::database::FilterMatcher; use rootasrole_core::database::env::{EnvLevel, ResolvedEnv}; use rootasrole_core::database::options::{ EnvBehavior, Level, PathBehavior, SAuthentication, SBounding, SInfo, SPassword, SPathOptions, - SPrivileged, STimeout, SUMask, WorkdirBehavior, + SPrivileged, STimeout, STty, SUMask, TtyOwner, WorkdirBehavior, }; use rootasrole_core::database::path::{PathLevel, ResolvedPath}; use rootasrole_core::database::score::SecurityMin; @@ -187,6 +187,8 @@ pub struct Opt<'a> { #[serde(default, skip_serializing_if = "Option::is_none")] pub password: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + pub tty: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub umask: Option, #[serde(default, flatten)] pub extra_fields: Value, @@ -206,6 +208,7 @@ impl<'a> Opt<'a> { workdir: Option>, timeout: Option, password: Option, + tty: Option, umask: Option, #[builder(default)] extra_fields: Value, ) -> Self { @@ -220,6 +223,7 @@ impl<'a> Opt<'a> { workdir, timeout, password, + tty, umask, extra_fields, } @@ -328,6 +332,7 @@ impl From> for rootasrole_core::database::options::Opt { .maybe_authentication(val.authentication) .maybe_timeout(val.timeout) .maybe_password(val.password) + .maybe_tty(val.tty) .build() } } @@ -505,6 +510,13 @@ impl<'c, 'a> BorrowedOptStack<'c, 'a> { .unwrap_or(PASSWORD_DELAY), } } + /// Who owns the pseudo-terminal of the command: the most specific level that says it, or + /// the caller. + pub fn calc_tty_owner(&self) -> TtyOwner { + self.get_opt_iter_rev() + .find_map(|o| o.tty.as_ref()?.owner) + .unwrap_or_default() + } pub fn calc_info(&self) -> SInfo { self.get_opt_iter_rev() .find_map(|o| o.execinfo) @@ -950,4 +962,26 @@ mod tests { assert_eq!(password.delay, Some(jiff::SignedDuration::from_secs(1))); assert!(serde_json::from_str::(r#"{"password": {"tries": 0}}"#).is_err()); } + + // the most specific level that says who owns the terminal wins, else the caller + #[test] + fn test_calc_tty_owner() { + let stack = BorrowedOptStack::new(None); + assert_eq!(stack.calc_tty_owner(), TtyOwner::Caller); + + let config: Opt = serde_json::from_str(r#"{"tty": {"owner": "target"}}"#).unwrap(); + let mut stack = BorrowedOptStack::new(Some(&config)); + assert_eq!(stack.calc_tty_owner(), TtyOwner::Target); + // a level with a tty option that does not name the owner inherits it + let role: Opt = serde_json::from_str(r#"{"tty": {"future": 1}}"#).unwrap(); + stack.role = Some(&role); + assert_eq!(stack.calc_tty_owner(), TtyOwner::Target); + let task = Opt::builder(Level::Task) + .tty(STty::builder().owner(TtyOwner::Caller).build()) + .build(); + stack.task = Some(&task); + assert_eq!(stack.calc_tty_owner(), TtyOwner::Caller); + + assert!(serde_json::from_str::(r#"{"tty": {"owner": "root"}}"#).is_err()); + } } diff --git a/src/dosr/lib.rs b/src/dosr/lib.rs index 7bc86047..bd6085a3 100644 --- a/src/dosr/lib.rs +++ b/src/dosr/lib.rs @@ -48,7 +48,7 @@ use bon::Builder; use capctl::{Cap, CapState}; use const_format::formatcp; use nix::unistd::{Uid, User}; -use rootasrole_core::database::options::SBounding; +use rootasrole_core::database::options::{SBounding, TtyOwner}; use rootasrole_core::util::{initialize_capabilities, with_privileges}; use rootasrole_core::{ Cred, @@ -590,7 +590,13 @@ fn main_inner() -> SrResult<()> { cargs.join(" ") ); - let status = match rootasrole_exec::runner::run(command_builder, PRE_EXEC_ORCHESTRATOR, None) { + let tty_owner = tty_owner(execcfg.tty_owner, &user.user, execcfg.cred.setuid.as_ref()); + let status = match rootasrole_exec::runner::run_with_pty_setup( + command_builder, + PRE_EXEC_ORCHESTRATOR, + None, + |pty| tty_owner.map_or(Ok(()), |owner| give_pty(pty, owner)), + ) { Ok(status) => status, Err(e) => { error!("{e}"); @@ -603,6 +609,30 @@ fn main_inner() -> SrResult<()> { exit_like(status) } +/// The user that gets the pseudo-terminal of the command, when it is not the caller: the user +/// that the command runs as, if the `tty` option says `target`. +fn tty_owner<'a>(owner: TtyOwner, caller: &User, target: Option<&'a User>) -> Option<&'a User> { + match owner { + TtyOwner::Target => target.filter(|target| target.uid != caller.uid), + TtyOwner::Caller => None, + } +} + +/// Give the pseudo-terminal to `owner`, with `CAP_CHOWN` only for that, before the command starts. +fn give_pty(pty: &rootasrole_exec::pty::Pty, owner: &User) -> io::Result<()> { + with_privileges(&[Cap::CHOWN], || pty.follower.set_owner(owner.uid.as_raw())).map_err(|e| { + error!( + "Cannot give the terminal {} to {}: {e}", + pty.path.to_string_lossy(), + owner.name + ); + io::Error::new( + e.kind(), + format!("cannot give the terminal to {}: {e}", owner.name), + ) + }) +} + /// How the command ended: its exit code, or the signal that killed it. #[derive(Debug, PartialEq, Eq)] enum CommandEnd { @@ -1190,6 +1220,26 @@ mod tests { ); } + // the terminal changes hands only with `target`, and only for another user + #[test] + fn test_tty_owner() { + let caller = User::from_uid(getuid()).unwrap().unwrap(); + let root = User::from_uid(Uid::from_raw(0)).unwrap().unwrap(); + let target = if caller.uid.is_root() { + User::from_name("nobody").unwrap().unwrap() + } else { + root + }; + assert_eq!( + tty_owner(TtyOwner::Target, &caller, Some(&target)).map(|u| u.uid), + Some(target.uid) + ); + assert_eq!(tty_owner(TtyOwner::Caller, &caller, Some(&target)), None); + assert_eq!(tty_owner(TtyOwner::Target, &caller, Some(&caller)), None); + assert_eq!(tty_owner(TtyOwner::Target, &caller, None), None); + assert_eq!(tty_owner(TtyOwner::Caller, &caller, None), None); + } + #[test] fn test_caps_outside_bounding() { use capctl::{Cap, CapSet}; From c7f4e79297d1a2c7d4b8057171f767b63d2313de Mon Sep 17 00:00:00 2001 From: LeChatP Date: Sun, 25 Oct 2026 21:53:27 +0100 Subject: [PATCH 69/88] feat: chsr edits the tty option at each level * feat: chsr [role R [task T]] options tty owner target|caller|unset, and options show tty * feat: unset removes the owner, then the option once nothing is left in it; the fields that this version does not know are kept * feat: the help lists the tty option, and a wrong command shows its usage; t stays the alias of timeout * tests: each level, the unknown fields, the refused values, timeout through its alias, the usage, and the saved file read back Co-Authored-By: Claude --- core/src/database/options.rs | 1 + src/chsr/cli/cli.pest | 10 ++++- src/chsr/cli/data.rs | 3 +- src/chsr/cli/mod.rs | 71 ++++++++++++++++++++++++++++++++++++ src/chsr/cli/pair.rs | 5 +++ src/chsr/cli/process.rs | 21 ++++++++++- src/chsr/cli/process/json.rs | 27 +++++++++++++- src/chsr/cli/usage.rs | 44 ++++++++++++++++++++++ 8 files changed, 177 insertions(+), 5 deletions(-) diff --git a/core/src/database/options.rs b/core/src/database/options.rs index 830d73d9..531463b2 100644 --- a/core/src/database/options.rs +++ b/core/src/database/options.rs @@ -58,6 +58,7 @@ pub enum OptType { Bounding, Timeout, Password, + Tty, Authentication, ExecInfo, UMask, diff --git a/src/chsr/cli/cli.pest b/src/chsr/cli/cli.pest index 5561791d..946fced2 100644 --- a/src/chsr/cli/cli.pest +++ b/src/chsr/cli/cli.pest @@ -195,12 +195,14 @@ caps_listing = { (whitelist | blacklist) ~ ((add | del | set) ~ capabilities // chsr o password set --tries 3 --delay 00:00:05 // chsr o password unset --tries --delay +// chsr o tty owner (target|caller|unset) + options_operations = { ("options" | "o") ~ opt_args } -opt_args = _{ opt_show | opt_path | opt_workdir | opt_env | opt_root | opt_bounding | opt_timeout | opt_password | opt_skip_auth | opt_execinfo | opt_mask } +opt_args = _{ opt_show | opt_path | opt_workdir | opt_env | opt_root | opt_bounding | opt_timeout | opt_password | opt_tty | opt_skip_auth | opt_execinfo | opt_mask } opt_show = _{ list ~ opt_show_arg? } // a longer name before its prefix: "authentication" before "auth" -opt_show_arg = { "all" | "path" | "env" | "root" | "bounding" | "timeout" | "password" | "authentication" | "auth" | "execinfo" | "info" | "umask" | "mask" | "workdir" } +opt_show_arg = { "all" | "path" | "env" | "root" | "bounding" | "timeout" | "password" | "tty" | "authentication" | "auth" | "execinfo" | "info" | "umask" | "mask" | "workdir" } opt_path = { "path" ~ (opt_path_args | help) } opt_path_args = _{ opt_path_setpolicy | opt_path_set | opt_path_listing } @@ -253,6 +255,10 @@ opt_skip_auth_args = { del | "skip" | "perform" } opt_execinfo = { ( "execinfo" | "info") ~ (opt_execinfo_args | help) } opt_execinfo_args = { del | "show" | "hide" } +opt_tty = { "tty" ~ (opt_tty_owner | help) } +opt_tty_owner = { "owner" ~ opt_tty_owner_args } +opt_tty_owner_args = { del | "target" | "caller" } + opt_mask = { ( "umask" | "mask") ~ (opt_mask_args | help) } opt_mask_args = { ASCII_DIGIT{1,4} | del } diff --git a/src/chsr/cli/data.rs b/src/chsr/cli/data.rs index 6a28ba5e..6dbc066c 100644 --- a/src/chsr/cli/data.rs +++ b/src/chsr/cli/data.rs @@ -11,7 +11,7 @@ use rootasrole_core::{ actor::{SActor, SGroups, SUserType}, options::{ EnvBehavior, EnvKey, OptType, PathBehavior, SAuthentication, SBounding, SInfo, - SPrivileged, SUMask, TimestampType, WorkdirBehavior, + SPrivileged, SUMask, TimestampType, TtyOwner, WorkdirBehavior, }, structs::{GroupsFrom, IdTask, SetBehavior}, }, @@ -157,6 +157,7 @@ pub struct Inputs { pub options_bounding: Option, pub options_auth: Option, pub options_execinfo: Option, + pub options_tty_owner: Option, pub options_umask: Option, pub options_workdir_policy: Option, pub convertion: Option, diff --git a/src/chsr/cli/mod.rs b/src/chsr/cli/mod.rs index 8390e2dd..93a19a9f 100644 --- a/src/chsr/cli/mod.rs +++ b/src/chsr/cli/mod.rs @@ -1820,6 +1820,77 @@ mod tests { assert!(saved["options"].get("password").is_none()); } + /// The tty option of `level`, as (owner, unknown fields). + fn tty(ctx: &TestContext, level: Level) -> Option<(Option, usize)> { + ctx.opt(level) + .as_ref() + .borrow() + .tty + .as_ref() + .map(|tty| (tty.owner, tty.extra_fields.len())) + } + + #[test] + fn test_options_tty() { + let name = "options_tty"; + let (mut ctx, _defer) = TestContext::new(name); + ctx.assert_command_success("o tty owner target"); + ctx.assert_command_success("r complete o tty owner caller"); + ctx.assert_command_success("r complete t t_complete o tty owner target"); + assert_eq!(tty(&ctx, Level::Global), Some((Some(TtyOwner::Target), 0))); + assert_eq!(tty(&ctx, Level::Role), Some((Some(TtyOwner::Caller), 0))); + assert_eq!(tty(&ctx, Level::Task), Some((Some(TtyOwner::Target), 0))); + // unset removes the option, unless it has fields that this version does not know + ctx.assert_command_success("o tty owner unset"); + assert_eq!(tty(&ctx, Level::Global), None); + ctx.opt(Level::Role) + .as_ref() + .borrow_mut() + .tty + .as_mut() + .unwrap() + .extra_fields + .insert("future".into(), Value::from(1)); + ctx.assert_command_success("r complete o tty owner unset"); + assert_eq!(tty(&ctx, Level::Role), Some((None, 1))); + ctx.assert_command_success("r complete o tty owner target"); + assert_eq!(tty(&ctx, Level::Role), Some((Some(TtyOwner::Target), 1))); + // refused, without a change; "t" stays the alias of timeout + for command in ["o tty owner root", "o tty owner", "o tty target", "o tty"] { + assert!(ctx.run_command(command).is_err(), "{command}"); + } + assert_eq!(tty(&ctx, Level::Global), None); + ctx.assert_command_success("o t set --type tty"); + ctx.assert_command_no_change("r complete t t_complete o show tty"); + // what is saved is what dosr reads + ctx.settings.save_all().unwrap(); + let saved: Value = + serde_json::from_str(&fs::read_to_string(format!("{RAR_CFG_PATH}.{name}")).unwrap()) + .unwrap(); + let role = saved["roles"] + .as_array() + .unwrap() + .iter() + .find(|role| role["name"] == "complete") + .unwrap(); + assert_eq!( + role["options"]["tty"], + serde_json::json!({"owner": "target", "future": 1}) + ); + let task = role["tasks"] + .as_array() + .unwrap() + .iter() + .find(|task| task["name"] == "t_complete") + .unwrap(); + assert_eq!( + task["options"]["tty"], + serde_json::json!({"owner": "target"}) + ); + assert!(saved["options"].get("tty").is_none()); + assert_eq!(saved["options"]["timeout"]["type"], "tty"); + } + #[test] fn test_all_main() { let (mut ctx, _defer) = TestContext::new("all_main"); diff --git a/src/chsr/cli/pair.rs b/src/chsr/cli/pair.rs index 2fb73a21..40b42382 100644 --- a/src/chsr/cli/pair.rs +++ b/src/chsr/cli/pair.rs @@ -512,6 +512,7 @@ fn match_pair(pair: &Pair, inputs: &mut Inputs) -> Result<(), Box Some(OptType::Bounding), "timeout" => Some(OptType::Timeout), "password" => Some(OptType::Password), + "tty" => Some(OptType::Tty), "authentication" | "auth" => Some(OptType::Authentication), "execinfo" | "info" => Some(OptType::ExecInfo), "umask" | "mask" => Some(OptType::UMask), @@ -569,6 +570,10 @@ fn match_pair(pair: &Pair, inputs: &mut Inputs) -> Result<(), Box { + inputs.action = InputAction::Set; // If del it will be overwritten by the parse loop + inputs.options_tty_owner = Some(pair.as_str().parse().unwrap_or_default()); + } Rule::opt_mask_args => { inputs.action = InputAction::Set; // If del it will be overwritten by the parse loop let value = pair.as_str(); diff --git a/src/chsr/cli/process.rs b/src/chsr/cli/process.rs index 4e4db2b8..2234217e 100644 --- a/src/chsr/cli/process.rs +++ b/src/chsr/cli/process.rs @@ -9,7 +9,8 @@ use json::{ edit_role_links, edit_task_purpose, env_set_policylist, env_setlist_add, env_setpolicy, env_whitelist_set, grant_revoke, path_purge, path_set, path_setlist2, path_setpolicy, role_add_del, set_authentication, set_bounding, set_env_override, set_execinfo, set_password, - set_privileged, set_timeout, set_umask, task_add_del, unset_password, unset_timeout, + set_privileged, set_timeout, set_tty_owner, set_umask, task_add_del, unset_password, + unset_timeout, }; use log::debug; @@ -343,6 +344,24 @@ pub fn process_input(storage: &mut FileSettings, inputs: Inputs) -> Result set_authentication(rconfig, role_id.as_ref(), task_id, None), + Inputs { + // chsr o tty owner target|caller + action: InputAction::Set, + role_id, + task_id, + options_tty_owner: Some(owner), + .. + } => set_tty_owner(rconfig, role_id.as_ref(), task_id, Some(owner)), + + Inputs { + // chsr o tty owner unset + action: InputAction::Del, + role_id, + task_id, + options_tty_owner: Some(_), + .. + } => set_tty_owner(rconfig, role_id.as_ref(), task_id, None), + Inputs { // chsr o execinfo hide|show action: InputAction::Set, diff --git a/src/chsr/cli/process/json.rs b/src/chsr/cli/process/json.rs index dbec1a56..135e46b8 100644 --- a/src/chsr/cli/process/json.rs +++ b/src/chsr/cli/process/json.rs @@ -14,7 +14,7 @@ use serde_json::Value; use rootasrole_core::database::{ options::{ EnvBehavior, EnvKey, Opt, OptStack, OptType, PathBehavior, SPassword, SPathOptions, - STimeout, SUMask, SWorkdirEither, SWorkdirSet, WorkdirBehavior, + STimeout, STty, SUMask, SWorkdirEither, SWorkdirSet, TtyOwner, WorkdirBehavior, }, structs::{ GroupsFrom, IdTask, RoleGetter, SCapabilities, SCommand, SGroupsEither, SRole, SSetgidSet, @@ -64,6 +64,7 @@ fn options_to_show(opt: &Opt, options_type: Option) -> serde_json::Resu Some(OptType::Bounding) => serde_json::to_string_pretty(&opt.bounding), Some(OptType::Timeout) => serde_json::to_string_pretty(&opt.timeout), Some(OptType::Password) => serde_json::to_string_pretty(&opt.password), + Some(OptType::Tty) => serde_json::to_string_pretty(&opt.tty), Some(OptType::Authentication) => serde_json::to_string_pretty(&opt.authentication), Some(OptType::ExecInfo) => serde_json::to_string_pretty(&opt.execinfo), Some(OptType::UMask) => serde_json::to_string_pretty(&opt.umask), @@ -779,6 +780,30 @@ pub fn set_execinfo( Ok(true) } +/// `chsr o tty owner`: sets or unsets the owner, then removes the option once nothing is left in +/// it; the fields that this version does not know are kept. +pub fn set_tty_owner( + rconfig: &Rc>, + role_id: Option<&String>, + task_id: Option, + owner: Option, +) -> Result> { + debug!("chsr o tty owner {owner:?}"); + perform_on_target_opt(rconfig, role_id, task_id, |opt: Rc>| { + let mut opt = opt.as_ref().borrow_mut(); + if let Some(owner) = owner { + opt.tty.get_or_insert_with(STty::default).owner = Some(owner); + } else if let Some(tty) = opt.tty.as_mut() { + tty.owner = None; + if tty.extra_fields.is_empty() { + opt.tty = None; + } + } + Ok(()) + })?; + Ok(true) +} + pub fn set_umask( rconfig: &Rc>, role_id: Option<&String>, diff --git a/src/chsr/cli/usage.rs b/src/chsr/cli/usage.rs index 97cd4816..81051c97 100644 --- a/src/chsr/cli/usage.rs +++ b/src/chsr/cli/usage.rs @@ -44,6 +44,7 @@ enum UsageSectionId { OptionsEnv, OptionsTimeout, OptionsPassword, + OptionsTty, OptionsAuth, OptionsExecinfo, OptionsUmask, @@ -345,6 +346,11 @@ const USAGE_OPTIONS_GENERAL: UsageSection = UsageSection { description: "Manage the password tries and the delay after a wrong one (set, unset).", indent: 1, }, + UsageItem { + name: "tty owner [owner]", + description: "Defines who owns the terminal of the command (unset, caller, target).", + indent: 1, + }, UsageItem { name: "authentication [policy]", description: "Defines if user needs to authenticate (unset, skip, perform).", @@ -536,6 +542,29 @@ const USAGE_OPTIONS_PASSWORD: UsageSection = UsageSection { ], }; +const USAGE_OPTIONS_TTY: UsageSection = UsageSection { + title: "Terminal options", + synopsis: &["chsr options tty owner [owner]"], + items: &[ + UsageItem { + name: "caller", + description: "The user who runs dosr owns the terminal (default).", + indent: 1, + }, + UsageItem { + name: "target", + description: "The user that the command runs as owns the terminal, like sudo.", + indent: 1, + }, + UsageItem { + name: "unset", + description: "Reset the owner of the terminal.", + indent: 1, + }, + ], + examples: &["chsr role gpg task sign options tty owner target"], +}; + const USAGE_OPTIONS_AUTH: UsageSection = UsageSection { title: "Authentication options", synopsis: &["chsr options authentication [policy]"], @@ -679,6 +708,7 @@ const fn usage_section(id: UsageSectionId) -> &'static UsageSection { UsageSectionId::OptionsEnv => &USAGE_OPTIONS_ENV, UsageSectionId::OptionsTimeout => &USAGE_OPTIONS_TIMEOUT, UsageSectionId::OptionsPassword => &USAGE_OPTIONS_PASSWORD, + UsageSectionId::OptionsTty => &USAGE_OPTIONS_TTY, UsageSectionId::OptionsAuth => &USAGE_OPTIONS_AUTH, UsageSectionId::OptionsExecinfo => &USAGE_OPTIONS_EXECINFO, UsageSectionId::OptionsUmask => &USAGE_OPTIONS_UMASK, @@ -782,6 +812,9 @@ fn rule_to_string(rule: Rule) -> String { Rule::list => "show, list, l", Rule::opt_timeout => "timeout", Rule::opt_password => "password", + Rule::opt_tty => "tty", + Rule::opt_tty_owner => "owner", + Rule::opt_tty_owner_args => "caller, target or unset", Rule::opt_path | Rule::path => "path", Rule::opt_env => "env", Rule::role_links | Rule::role_link_kind => "ssd, parents", @@ -848,6 +881,7 @@ const OPTIONS_EXECINFO_SECTIONS: &[UsageSectionId] = &[UsageSectionId::OptionsEx const OPTIONS_UMASK_SECTIONS: &[UsageSectionId] = &[UsageSectionId::OptionsUmask]; const OPTIONS_TIMEOUT_SECTIONS: &[UsageSectionId] = &[UsageSectionId::OptionsTimeout]; const OPTIONS_PASSWORD_SECTIONS: &[UsageSectionId] = &[UsageSectionId::OptionsPassword]; +const OPTIONS_TTY_SECTIONS: &[UsageSectionId] = &[UsageSectionId::OptionsTty]; const OPTIONS_PATH_SECTIONS: &[UsageSectionId] = &[UsageSectionId::OptionsPath]; const OPTIONS_WORKDIR_SECTIONS: &[UsageSectionId] = &[UsageSectionId::OptionsWorkdir]; const OPTIONS_ENV_SECTIONS: &[UsageSectionId] = &[UsageSectionId::OptionsEnv]; @@ -884,6 +918,9 @@ const fn usage_sections_for_rule(rule: Rule) -> Option<&'static [UsageSectionId] | Rule::opt_password_operations | Rule::opt_password_t_arg | Rule::opt_password_d_arg => Some(OPTIONS_PASSWORD_SECTIONS), + Rule::opt_tty | Rule::opt_tty_owner | Rule::opt_tty_owner_args => { + Some(OPTIONS_TTY_SECTIONS) + } Rule::opt_path | Rule::opt_path_args | Rule::opt_path_set @@ -987,6 +1024,13 @@ mod tests { } } + #[test] + fn maps_tty_rules_to_tty_section() { + for rule in [Rule::opt_tty, Rule::opt_tty_owner, Rule::opt_tty_owner_args] { + assert_eq!(usage_sections_for_rule(rule), Some(OPTIONS_TTY_SECTIONS)); + } + } + #[test] fn returns_none_for_unknown_rule() { assert_eq!(usage_sections_for_rule(Rule::EOI), None); From d35f82c79162f2ac62addcc7d37a96ec81cd2ceb Mon Sep 17 00:00:00 2001 From: LeChatP Date: Sun, 25 Oct 2026 21:54:22 +0100 Subject: [PATCH 70/88] tests: end-to-end checks of the owner of the terminal of a command run as another user * tests: a task run as nobody through script sees a terminal of the caller that it cannot reopen by its name, and with "tty": {"owner": "target"} a terminal of nobody that it reopens, as pinentry does through GPG_TTY * tests: script gets no input, otherwise it copies into the terminal what is left on the input of the provisioner * tests: 24 checks pass on the Debian and Arch machines Co-Authored-By: Claude --- tests/vagrant/e2e.sh | 46 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/tests/vagrant/e2e.sh b/tests/vagrant/e2e.sh index ed93823f..fccf761e 100755 --- a/tests/vagrant/e2e.sh +++ b/tests/vagrant/e2e.sh @@ -17,6 +17,7 @@ PAM_ENV_FILE=/etc/rar-e2e.env BIG_GROUP=rar-e2e-big GROUP_BACKUP=/etc/.group.rar-e2e GSHADOW_BACKUP=/etc/.gshadow.rar-e2e +TTY_SCRIPT=/tmp/rar-e2e-tty.sh FAILED=0 # 0: pass, 77: skip, anything else: fail @@ -128,6 +129,17 @@ setup() { "cred": { "setgid": "rar-e2e-missing" }, "commands": ["/usr/bin/whoami"] }, + { + "name": "tty-caller", + "cred": { "setuid": "nobody" }, + "commands": ["/usr/bin/sh $TTY_SCRIPT"] + }, + { + "name": "tty-target", + "options": { "tty": { "owner": "target" } }, + "cred": { "setuid": "nobody" }, + "commands": ["/usr/bin/sh $TTY_SCRIPT"] + }, { "name": "password", "options": { @@ -178,6 +190,7 @@ cleanup() { systemctl daemon-reload remove_policy "$PLANTED_POLICY" remove_policy "$E2E_POLICY" + rm -f "$TTY_SCRIPT" userdel -r "$TEST_USER" 2>/dev/null } @@ -458,6 +471,37 @@ info_shows_no_capabilities_as_none() { grep -qx 'With capabilities: None' } +# the owner of the terminal of a task run as nobody through a terminal, and whether the command +# can reopen it by its name, as pinentry does through GPG_TTY +terminal_seen_by() { + printf '%s\n' 't=$(tty)' 'stat -c %U "$t"' \ + 'if { true <>"$t"; } 2>/dev/null; then echo reopened; else echo refused; fi' >"$TTY_SCRIPT" + chmod 644 "$TTY_SCRIPT" + # no input: script would copy into the terminal what is left on the input of the provisioner + as_user script -qec "dosr -r e2e -t $1 /usr/bin/sh $TTY_SCRIPT" /dev/null &2 + return 1 + } +} + +# "tty": {"owner": "target"} gives the terminal to the user that the command runs as +terminal_given_to_the_target() { + local out + out=$(terminal_seen_by tty-target) + [ "$out" = "$(printf '%s\n' nobody reopened)" ] || { + echo "$out" >&2 + return 1 + } +} + trap cleanup EXIT setup check "the installed binaries belong to root, dosr has its capabilities" installed_binaries_belong_to_root @@ -482,4 +526,6 @@ check "a task that changes the user gives the groups of the target user" groups_ check "from: caller keeps the groups of the caller" groups_of_the_caller_with_from check "a task whose group does not exist is left out" missing_group_leaves_the_task_out check "a top-level alternation is anchored on both sides" alternation_is_anchored +check "the terminal of a command run as another user stays with the caller" terminal_stays_with_the_caller +check "tty owner target gives the terminal to the user of the command" terminal_given_to_the_target exit "$FAILED" From 3b8624a5dff4e971925e76e0215f493c20238cc7 Mon Sep 17 00:00:00 2001 From: LeChatP Date: Sun, 25 Oct 2026 22:18:02 +0100 Subject: [PATCH 71/88] feat: the default owner of the terminal is the build variable RAR_TTY_OWNER * feat: RAR_TTY_OWNER in the [env] of .cargo/config.toml, read with env! like the defaults of the other options; caller upstream, so the default exposes nothing more than 4.1.0, and a build that wants the behaviour of sudo sets target * feat: TtyOwner::const_parse; a value that is neither caller nor target stops the build; TtyOwner::default() is the build-time value * chore: the Debian copies of [env] need RAR_TTY_OWNER, like RAR_PASSWORD_TRIES and RAR_PASSWORD_DELAY * tests: the shipped value is caller, the policy and dosr fall back to it Co-Authored-By: Claude --- .cargo/config.toml | 2 ++ core/src/database/options.rs | 39 +++++++++++++++++++++++++++++------- core/src/util.rs | 6 +++++- src/dosr/finder/mod.rs | 9 +++++++-- src/dosr/finder/options.rs | 10 ++++----- 5 files changed, 51 insertions(+), 15 deletions(-) diff --git a/.cargo/config.toml b/.cargo/config.toml index a126cbdf..0e5f0739 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -50,6 +50,8 @@ RAR_TIMEOUT_DURATION = "00:05:00" RAR_TIMEOUT_MAX_USAGE = "" RAR_PASSWORD_TRIES = "3" RAR_PASSWORD_DELAY = "00:00:05" +# Who owns the terminal of a command run as another user: caller, or target like sudo. +RAR_TTY_OWNER = "caller" RAR_PATH_DEFAULT = "delete" RAR_PATH_ADD_LIST = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/snap/bin" RAR_PATH_REMOVE_LIST = "" diff --git a/core/src/database/options.rs b/core/src/database/options.rs index 531463b2..01021c6a 100644 --- a/core/src/database/options.rs +++ b/core/src/database/options.rs @@ -23,7 +23,7 @@ use crate::util::{ ENV_OVERRIDE_BEHAVIOR, ENV_PATH_ADD_LIST_SLICE, ENV_PATH_BEHAVIOR, ENV_PATH_REMOVE_LIST_SLICE, ENV_SET_LIST, HARDENED_ENUM_VALUE_0, HARDENED_ENUM_VALUE_1, HARDENED_ENUM_VALUE_2, HARDENED_ENUM_VALUE_3, INFO, PASSWORD_DELAY, PASSWORD_TRIES, PRIVILEGED, TIMEOUT_DURATION, - TIMEOUT_MAX_USAGE, TIMEOUT_TYPE, UMASK, is_valid_env_name, is_valid_env_pattern, + TIMEOUT_MAX_USAGE, TIMEOUT_TYPE, TTY_OWNER, UMASK, is_valid_env_name, is_valid_env_pattern, }; use super::{ @@ -132,19 +132,39 @@ pub struct SPassword { } /// Who owns the pseudo-terminal that dosr gives to the command. -#[derive(Serialize, Deserialize, PartialEq, Eq, Debug, Clone, Copy, Default)] +/// +/// Its default is the build-time one (`RAR_TTY_OWNER`). +#[derive(Serialize, Deserialize, PartialEq, Eq, Debug, Clone, Copy)] #[serde(rename_all = "kebab-case")] #[repr(u32)] pub enum TtyOwner { /// The user who runs dosr. The processes of the user that the command runs as cannot open /// the terminal by its name: a program that does, such as pinentry through `GPG_TTY`, fails. - #[default] Caller = HARDENED_ENUM_VALUE_0, /// The user that the command runs as, like sudo. Its other processes can then open the /// terminal while the command runs, and read what is typed into it. Target = HARDENED_ENUM_VALUE_1, } +impl Default for TtyOwner { + fn default() -> Self { + TTY_OWNER + } +} + +impl TtyOwner { + #[must_use] + /// # Panics + /// Panics if the input string does not match any of the valid ``TtyOwner`` variants. + pub const fn const_parse(input: &str) -> Self { + match input { + _ if eq_str(input, "caller") => Self::Caller, + _ if eq_str(input, "target") => Self::Target, + _ => panic!("fail to parse TtyOwner"), + } + } +} + impl FromStr for TtyOwner { type Err = String; @@ -161,7 +181,8 @@ impl FromStr for TtyOwner { /// The pseudo-terminal of the command, when dosr runs from a terminal. /// -/// A level that does not set `owner` inherits it from the upper levels, down to `caller`. +/// A level that does not set `owner` inherits it from the upper levels, down to the build-time +/// default (`RAR_TTY_OWNER`). #[derive(Serialize, Deserialize, PartialEq, Eq, Debug, Clone, Default, Builder)] pub struct STty { #[serde(default, skip_serializing_if = "Option::is_none")] @@ -736,7 +757,7 @@ impl Opt { .delay(PASSWORD_DELAY) .build(), ) - .tty(STty::builder().owner(TtyOwner::default()).build()) + .tty(STty::builder().owner(TTY_OWNER).build()) .build() } } @@ -2072,13 +2093,17 @@ mod tests { assert!("unset".parse::().is_err()); } - // the terminal stays with the caller unless the policy says otherwise + // the terminal stays with the caller unless the policy or the build says otherwise #[test] fn test_tty_owner_default_and_inheritance() { + assert_eq!(crate::util::TTY_OWNER, TtyOwner::Caller); + assert_eq!(TtyOwner::default(), crate::util::TTY_OWNER); assert_eq!( Opt::level_default().tty.unwrap().owner, - Some(TtyOwner::Caller) + Some(crate::util::TTY_OWNER) ); + assert_eq!(TtyOwner::const_parse("caller"), TtyOwner::Caller); + assert_eq!(TtyOwner::const_parse("target"), TtyOwner::Target); let config = SPolicy::builder() .role( SRole::builder("test") diff --git a/core/src/util.rs b/core/src/util.rs index 28e22dab..ea2ab63d 100644 --- a/core/src/util.rs +++ b/core/src/util.rs @@ -24,7 +24,7 @@ use std::num::NonZeroU8; use crate::database::options::{ EnvBehavior, PathBehavior, SAuthentication, SBounding, SInfo, SPrivileged, SUMask, - TimestampType, WorkdirBehavior, + TimestampType, TtyOwner, WorkdirBehavior, }; #[cfg(feature = "finder")] @@ -207,6 +207,10 @@ pub const UMASK: SUMask = SUMask(result::unwrap_or!( pub const INFO: SInfo = SInfo::const_parse(env!("RAR_EXEC_INFO_DISPLAY")); +/// Who owns the terminal of a command run as another user, when the policy does not say it +/// (`RAR_TTY_OWNER`): `caller`, or `target` like sudo. A value that is neither stops the build. +pub const TTY_OWNER: TtyOwner = TtyOwner::const_parse(env!("RAR_TTY_OWNER")); + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Copy)] #[serde(rename_all = "lowercase")] #[repr(u8)] diff --git a/src/dosr/finder/mod.rs b/src/dosr/finder/mod.rs index 7b471b0f..7482a36b 100644 --- a/src/dosr/finder/mod.rs +++ b/src/dosr/finder/mod.rs @@ -1484,7 +1484,8 @@ mod tests { } } - // the tty option of the policy reaches the settings of the execution, the caller by default + // the tty option of the policy reaches the settings of the execution, the build-time default + // without it #[test] fn test_find_best_exec_settings_tty_owner() { for method in [StorageMethod::JSON, StorageMethod::CBOR] { @@ -1511,7 +1512,11 @@ mod tests { method, 0o600, ); - assert_eq!(tty_owner(&root), TtyOwner::Caller, "{method}"); + assert_eq!( + tty_owner(&root), + rootasrole_core::util::TTY_OWNER, + "{method}" + ); roles[0]["tasks"][0]["options"] = serde_json::json!({"tty": {"owner": "target"}}); write_file(&root, &root_settings(method, &root, roles), method, 0o600); assert_eq!(tty_owner(&root), TtyOwner::Target, "{method}"); diff --git a/src/dosr/finder/options.rs b/src/dosr/finder/options.rs index 76452157..60656735 100644 --- a/src/dosr/finder/options.rs +++ b/src/dosr/finder/options.rs @@ -16,7 +16,7 @@ use rootasrole_core::database::workdir::{ResolvedWorkdir, WorkdirLevel}; use rootasrole_core::util::{ AUTHENTICATION, BOUNDING, ENV_DEFAULT_BEHAVIOR, ENV_OVERRIDE_BEHAVIOR, ENV_PATH_BEHAVIOR, INFO, PASSWORD_DELAY, PASSWORD_TRIES, PRIVILEGED, TIMEOUT_DURATION, TIMEOUT_MAX_USAGE, TIMEOUT_TYPE, - UMASK, is_valid_env_name, is_valid_env_pattern, + TTY_OWNER, UMASK, is_valid_env_name, is_valid_env_pattern, }; use serde::{Deserialize, Serialize}; @@ -511,11 +511,11 @@ impl<'c, 'a> BorrowedOptStack<'c, 'a> { } } /// Who owns the pseudo-terminal of the command: the most specific level that says it, or - /// the caller. + /// the build-time default. pub fn calc_tty_owner(&self) -> TtyOwner { self.get_opt_iter_rev() .find_map(|o| o.tty.as_ref()?.owner) - .unwrap_or_default() + .unwrap_or(TTY_OWNER) } pub fn calc_info(&self) -> SInfo { self.get_opt_iter_rev() @@ -963,11 +963,11 @@ mod tests { assert!(serde_json::from_str::(r#"{"password": {"tries": 0}}"#).is_err()); } - // the most specific level that says who owns the terminal wins, else the caller + // the most specific level that says who owns the terminal wins, else the build-time default #[test] fn test_calc_tty_owner() { let stack = BorrowedOptStack::new(None); - assert_eq!(stack.calc_tty_owner(), TtyOwner::Caller); + assert_eq!(stack.calc_tty_owner(), TTY_OWNER); let config: Opt = serde_json::from_str(r#"{"tty": {"owner": "target"}}"#).unwrap(); let mut stack = BorrowedOptStack::new(Some(&config)); From 5e4eb6a2323715cac10cb75ee7f19b0f27ba260d Mon Sep 17 00:00:00 2001 From: LeChatP Date: Mon, 19 Oct 2026 21:48:24 +0200 Subject: [PATCH 72/88] docs: rewriting the file configuration completely. Note: The content of this document is based on human writing. However, the content is cross-checked with the code behavior by AI. Also we both verified edge cases of the taken example for ensuring correctness between code and docs. See recent dozens of fix commits about it. --- book/book.css | 54 ++ book/book.toml | 1 + book/src/SUMMARY.md | 2 +- book/src/assets/js/panzoom-init.js | 21 + book/src/assets/js/svg-pan-zoom.min.js | 28 + book/src/assets/policy.svg | 1 + book/src/chsr/configuration.md | 813 +++++++++++++++++++++++++ book/src/chsr/file-config.md | 195 ------ book/src/policy.puml | 216 +++++++ resources/man/en_US.md | 6 + resources/man/fr_FR.md | 12 +- 11 files changed, 1150 insertions(+), 199 deletions(-) create mode 100644 book/src/assets/js/panzoom-init.js create mode 100644 book/src/assets/js/svg-pan-zoom.min.js create mode 100644 book/src/assets/policy.svg create mode 100644 book/src/chsr/configuration.md delete mode 100644 book/src/chsr/file-config.md create mode 100644 book/src/policy.puml diff --git a/book/book.css b/book/book.css index 2b9cf9f8..259d8717 100644 --- a/book/book.css +++ b/book/book.css @@ -1,5 +1,28 @@ @import "assets/css/excon.css"; +/* Conteneur d'affichage pour les schémas interactifs */ +.diagram-container { + width: 100%; + height: 550px; + border: 1px solid var(--table-border-color, #ccc); + border-radius: 6px; + margin: 1.5rem 0; + overflow: hidden; + background-color: var(--bg); +} + +.diagram-container object { + width: 100%; + height: 100%; +} + +/* Inversion de couleurs pour les thèmes sombres (Option 2) */ +.coal .diagram-container, +.navy .diagram-container, +.ayu .diagram-container { + filter: invert(0.9) hue-rotate(180deg); +} + h1, h2, h3, h4, h5, h6 { font-family: 'Excon-Variable', sans-serif; font-weight: 900; @@ -64,6 +87,37 @@ blockquote.caroot:after { bottom: 0; } +.human-written { + position: relative; + margin: 1.5em 0 1.5em; + padding: 2.4em 1.2em 0.2em; + border: 1px dashed #dc7228; + border-left: 4px solid #dc7228; + border-radius: 4px; +} + +.human-written h2, .human-written h3, .human-written h4, .human-written h5, .human-written h6 { + margin-block-start: 0em; +} + +.human-written::before { + content: "\270D Human-written"; + position: absolute; + top: 0; + right: 0; + padding: 0.2em 0.8em; + font-family: 'Excon-Variable', sans-serif; + font-size: 0.8em; + font-weight: 700; + color: #fff; + background-color: #c2410c; + border-bottom-left-radius: 4px; +} + +.human-written[data-by]::before { + content: "\270D Written by " attr(data-by); +} + .part-title { font-family: 'Excon-Bold', sans-serif; } diff --git a/book/book.toml b/book/book.toml index 0e821398..ac308c80 100644 --- a/book/book.toml +++ b/book/book.toml @@ -12,6 +12,7 @@ edit-url-template = "https://github.com/LeChatP/RootAsRole/edit/master/book/{pat theme = "theme" default-theme = "rust" additional-css = ["book.css"] +additional-js = ["src/assets/js/svg-pan-zoom.min.js", "src/assets/js/panzoom-init.js"] mathjax-support = true [preprocessor.graphviz] diff --git a/book/src/SUMMARY.md b/book/src/SUMMARY.md index ce4d7973..35f33b84 100644 --- a/book/src/SUMMARY.md +++ b/book/src/SUMMARY.md @@ -19,7 +19,7 @@ # Policy & Configuration - [Policy Model and Inheritance](policy/model.md) - - [Configuration File Format](chsr/file-config.md) + - [Configuration File Format](chsr/configuration.md) - [File Format Conversion (JSON/CBOR)](chsr/convert.md) # Deep Dives & Architecture diff --git a/book/src/assets/js/panzoom-init.js b/book/src/assets/js/panzoom-init.js new file mode 100644 index 00000000..126e6348 --- /dev/null +++ b/book/src/assets/js/panzoom-init.js @@ -0,0 +1,21 @@ +window.addEventListener("DOMContentLoaded", () => { + // Cible toutes les balises ou