review: address all 13 Copilot review comments

- Fix cargo build --release --workspace in test scripts
- Document setuid-on-symlinks as intentional in Makefile
- Warn (not abort) on subid allocation failure in useradd
- Propagate subid file read errors instead of unwrap_or_default
- Update SELinux docs to describe best-effort behavior
- Add env_clear() to getfattr subprocess in selinux.rs
- Use hardening::sanitized_env() in nscd.rs instead of local reimpl
- Create testuser in pam-e2e.sh, add cleanup
- Document su-as-root limitation in pam-e2e.sh
- Fix set -e exit code capture in gnu-compat.sh
- Add 5 date validation unit tests in chage
- Wire audit::log_user_event into all 7 tools
This commit is contained in:
Pierre Warnier
2026-03-24 15:42:08 +01:00
parent 3fbeaf448c
commit 5d8ac9587d
13 changed files with 138 additions and 27 deletions
+3 -1
View File
@@ -20,7 +20,9 @@ install: build
@for tool in $(TOOLS); do \
ln -sf shadow-rs $(DESTDIR)$(BINDIR)/$$tool; \
done
@# Setuid-root for tools that need it
@# Setuid-root for tools that need it. chmod on symlinks affects the
@# underlying multicall binary — this is intentional for drop-in
@# replacement since all traditional shadow-utils tools are setuid-root.
@for tool in passwd chfn chsh newgrp; do \
chmod 4755 $(DESTDIR)$(BINDIR)/$$tool 2>/dev/null || true; \
done
+5 -4
View File
@@ -12,6 +12,8 @@
use std::process::Command;
use crate::hardening;
/// Invalidate the `nscd` and `sssd` caches for the given database.
///
/// The `database` should be one of `"passwd"`, `"shadow"`, or `"group"`.
@@ -23,15 +25,14 @@ use std::process::Command;
/// caller's full (potentially tainted) env from leaking into child processes
/// running in a setuid context.
pub fn invalidate_cache(database: &str) {
// Minimal, known-safe environment for child processes.
let clean_env = [("PATH", "/usr/bin:/bin:/usr/sbin:/sbin")];
let safe_env = hardening::sanitized_env();
// Use absolute paths to avoid PATH-based lookups in setuid context.
let _ = Command::new("/usr/sbin/nscd")
.arg("-i")
.arg(database)
.env_clear()
.envs(clean_env)
.envs(safe_env.iter().map(|(k, v)| (k, v)))
.status();
// sssd: sss_cache with the appropriate flag
@@ -43,6 +44,6 @@ pub fn invalidate_cache(database: &str) {
let _ = Command::new("/usr/sbin/sss_cache")
.arg(flag)
.env_clear()
.envs(clean_env)
.envs(safe_env.iter().map(|(k, v)| (k, v)))
.status();
}
+7 -6
View File
@@ -17,11 +17,9 @@ use crate::error::ShadowError;
/// Copy the `SELinux` security context from the source file to the destination.
///
/// If `SELinux` is not available or not enforcing, this is a no-op.
///
/// # Errors
///
/// Returns `ShadowError` if the context cannot be read or set.
/// Best-effort: silently succeeds if `SELinux` is not available, not enforcing,
/// or if context operations fail. This matches GNU shadow-utils behavior where
/// SELinux context handling is non-fatal.
pub fn copy_file_context(source: &Path, dest: &Path) -> Result<(), ShadowError> {
// Implementation requires libselinux FFI.
// For now, attempt to use the `setfilecon` command-line tool as a fallback.
@@ -42,6 +40,8 @@ fn get_file_context(path: &Path) -> Option<String> {
.arg("-n")
.arg("security.selinux")
.arg(path)
.env_clear()
.env("PATH", "/usr/bin:/bin:/usr/sbin:/sbin")
.output()
.ok()?;
@@ -77,7 +77,8 @@ fn set_file_context(path: &Path, context: &str) -> Result<(), ShadowError> {
/// Restore the default `SELinux` context for a file based on policy.
///
/// Equivalent to `restorecon <path>`.
/// Best-effort equivalent of `restorecon <path>`. Silently succeeds if
/// `SELinux` is not available or `restorecon` is not installed.
pub fn restore_default_context(path: &Path) -> Result<(), ShadowError> {
let _ = std::process::Command::new("/usr/sbin/restorecon")
.arg(path)
+40
View File
@@ -1024,6 +1024,46 @@ mod tests {
// Exit code constants consistency
// -----------------------------------------------------------------------
#[test]
fn test_reject_feb_29_non_leap_year() {
assert!(
parse_yyyy_mm_dd("2025-02-29").is_err(),
"2025 is not a leap year, Feb 29 should be rejected"
);
}
#[test]
fn test_reject_feb_31() {
assert!(
parse_yyyy_mm_dd("2025-02-31").is_err(),
"February never has 31 days"
);
}
#[test]
fn test_accept_feb_29_leap_year() {
assert!(
parse_yyyy_mm_dd("2024-02-29").is_ok(),
"2024 is a leap year, Feb 29 should be accepted"
);
}
#[test]
fn test_reject_apr_31() {
assert!(
parse_yyyy_mm_dd("2025-04-31").is_err(),
"April has 30 days, day 31 should be rejected"
);
}
#[test]
fn test_accept_jan_31() {
assert!(
parse_yyyy_mm_dd("2025-01-31").is_ok(),
"January has 31 days, should be accepted"
);
}
#[test]
fn test_exit_code_constants() {
assert_eq!(exit_codes::SUCCESS, 0);
+3
View File
@@ -15,6 +15,7 @@ use clap::{Arg, ArgAction, Command};
use uucore::error::{UError, UResult};
use shadow_core::atomic;
use shadow_core::audit;
use shadow_core::group::{self, GroupEntry};
use shadow_core::gshadow::{self, GshadowEntry};
use shadow_core::lock::FileLock;
@@ -195,6 +196,8 @@ fn do_groupadd(matches: &clap::ArgMatches) -> UResult<()> {
nscd::invalidate_cache("group");
audit::log_user_event("ADD_GROUP", &group_name, gid, true);
Ok(())
}
+3
View File
@@ -15,6 +15,7 @@ use clap::{Arg, Command};
use uucore::error::{UError, UResult};
use shadow_core::atomic;
use shadow_core::audit;
use shadow_core::group::{self, GroupEntry};
use shadow_core::gshadow::{self, GshadowEntry};
use shadow_core::lock::FileLock;
@@ -193,6 +194,8 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> {
nscd::invalidate_cache("group");
audit::log_user_event("DEL_GROUP", group_name, target_gid, true);
Ok(())
}
+5
View File
@@ -15,6 +15,7 @@ use clap::{Arg, ArgAction, Command};
use uucore::error::{UError, UResult};
use shadow_core::atomic;
use shadow_core::audit;
use shadow_core::group::{self};
use shadow_core::gshadow::{self};
use shadow_core::lock::FileLock;
@@ -184,6 +185,8 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> {
entries[idx].name.clone_from(name);
}
let modified_gid = entries[idx].gid;
// Write /etc/group.
atomic::atomic_write(&group_path, |f| group::write_group(&entries, f)).map_err(|e| {
GroupmodError::CantUpdate(format!("cannot write {}: {e}", group_path.display()))
@@ -220,6 +223,8 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> {
nscd::invalidate_cache("group");
audit::log_user_event("MOD_GROUP", group_name, modified_gid, true);
Ok(())
}
+15
View File
@@ -13,6 +13,7 @@ use std::path::Path;
use clap::{Arg, ArgAction, Command};
use shadow_core::audit;
use shadow_core::lock::FileLock;
use shadow_core::shadow::{self, ShadowEntry};
use shadow_core::sysroot::SysRoot;
@@ -562,6 +563,13 @@ fn cmd_pam_change(matches: &clap::ArgMatches, _target_user: &str) -> UResult<()>
return Err(PasswdError::PamError(e.to_string()).into());
}
audit::log_user_event(
"CHNG_PASSWD",
_target_user,
nix::unistd::getuid().as_raw(),
true,
);
Ok(())
}
@@ -811,6 +819,13 @@ where
drop(lock);
nscd::invalidate_cache("shadow");
audit::log_user_event(
"CHNG_PASSWD",
username,
nix::unistd::getuid().as_raw(),
true,
);
if !quiet {
uucore::show_error!("{action} for user {username}");
}
+25 -7
View File
@@ -20,6 +20,7 @@ use std::path::Path;
use clap::{Arg, ArgAction, Command};
use shadow_core::atomic;
use shadow_core::audit;
use shadow_core::group::{self, GroupEntry};
use shadow_core::gshadow::{self, GshadowEntry};
use shadow_core::lock::FileLock;
@@ -596,22 +597,26 @@ fn do_useradd(opts: &UseraddOptions) -> UResult<()> {
// Step 14: Allocate subordinate UID/GID ranges for rootless containers.
// Only done when the relevant file exists (matching GNU shadow-utils behavior).
let subuid_path = opts.root.subuid_path();
if subuid_path.exists() {
let _ = append_subid_entry(
if subuid_path.exists()
&& let Err(e) = append_subid_entry(
&subuid_path,
&opts.login,
100_000 + u64::from(uid) * 65_536,
65_536,
);
)
{
uucore::show_error!("warning: failed to add subordinate UID range: {e}");
}
let subgid_path = opts.root.subgid_path();
if subgid_path.exists() {
let _ = append_subid_entry(
if subgid_path.exists()
&& let Err(e) = append_subid_entry(
&subgid_path,
&opts.login,
100_000 + u64::from(gid) * 65_536,
65_536,
);
)
{
uucore::show_error!("warning: failed to add subordinate GID range: {e}");
}
// Step 15: Add to supplementary groups.
@@ -630,6 +635,9 @@ fn do_useradd(opts: &UseraddOptions) -> UResult<()> {
nscd::invalidate_cache("passwd");
nscd::invalidate_cache("group");
// Step 18: Audit log.
audit::log_user_event("ADD_USER", &opts.login, uid, true);
Ok(())
}
@@ -868,7 +876,17 @@ fn append_subid_entry(path: &Path, name: &str, start: u64, count: u64) -> UResul
UseraddError::CannotUpdatePasswd(format!("cannot lock {}: {e}", path.display()))
})?;
let mut entries = subid::read_subid_file(path).unwrap_or_default();
let mut entries = match subid::read_subid_file(path) {
Ok(e) => e,
Err(e) => {
uucore::show_error!("warning: cannot read {}: {e}", path.display());
return Err(UseraddError::CannotUpdatePasswd(format!(
"cannot read {}: {e}",
path.display()
))
.into());
}
};
// Don't add a duplicate entry.
if entries.iter().any(|e| e.name == name) {
+12 -4
View File
@@ -14,6 +14,7 @@ use std::path::Path;
use clap::{Arg, ArgAction, Command};
use uucore::error::{UError, UResult};
use shadow_core::audit;
use shadow_core::group::{self};
use shadow_core::gshadow::{self};
use shadow_core::lock::FileLock;
@@ -97,12 +98,17 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> {
return Err(UserdelError::CantUpdatePasswd("Permission denied.".into()).into());
}
// Read the user's home directory from /etc/passwd BEFORE removing the entry.
// Read the user's home directory and UID from /etc/passwd BEFORE removing
// the entry (needed for home removal and audit logging).
let passwd_path = root.passwd_path();
let pre_entries = passwd::read_passwd_file(&passwd_path)
.map_err(|e| UserdelError::CantUpdatePasswd(format!("cannot read passwd: {e}")))?;
let saved_uid = pre_entries
.iter()
.find(|e| e.name == *login)
.map_or(0, |e| e.uid);
let saved_home = if remove_home {
let entries = passwd::read_passwd_file(&passwd_path)
.map_err(|e| UserdelError::CantUpdatePasswd(format!("cannot read passwd: {e}")))?;
entries
pre_entries
.iter()
.find(|e| e.name == *login)
.map(|e| e.home.clone())
@@ -151,6 +157,8 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> {
nscd::invalidate_cache("passwd");
nscd::invalidate_cache("group");
audit::log_user_event("DEL_USER", login, saved_uid, true);
Ok(())
}
+4
View File
@@ -14,6 +14,7 @@ use std::path::Path;
use clap::{Arg, ArgAction, Command};
use uucore::error::{UError, UResult};
use shadow_core::audit;
use shadow_core::group::{self};
use shadow_core::lock::FileLock;
use shadow_core::passwd::{self};
@@ -280,6 +281,9 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> {
nscd::invalidate_cache("passwd");
nscd::invalidate_cache("group");
audit::log_user_event("MOD_USER", login, new_uid, true);
Ok(())
}
+3 -3
View File
@@ -6,7 +6,7 @@
set -euo pipefail
PASS=0; FAIL=0; SKIP=0
cargo build --release 2>/dev/null
cargo build --release --workspace 2>/dev/null
RS=./target/release
compare() {
@@ -28,8 +28,8 @@ compare() {
compare_exit() {
local name="$1" our_cmd="$2" gnu_cmd="$3"
local our_rc gnu_rc
eval "$our_cmd" >/dev/null 2>&1; our_rc=$?
eval "$gnu_cmd" >/dev/null 2>&1; gnu_rc=$?
our_rc=0; eval "$our_cmd" >/dev/null 2>&1 || our_rc=$?
gnu_rc=0; eval "$gnu_cmd" >/dev/null 2>&1 || gnu_rc=$?
if [ "$our_rc" = "$gnu_rc" ]; then
echo " PASS: $name (exit $our_rc)"
((PASS++))
+13 -2
View File
@@ -3,14 +3,22 @@
# Usage: docker compose run --rm debian bash tests/pam-e2e.sh
set -euo pipefail
cargo build --release 2>/dev/null
cargo build --release --workspace 2>/dev/null
echo "=== Setting up test user ==="
# Create testuser if it does not already exist.
if ! id testuser >/dev/null 2>&1; then
useradd -m testuser
fi
# Give testuser a known password
echo "testuser:oldpassword" | chpasswd
echo "=== Verifying old password works ==="
echo "oldpassword" | su -c "echo 'auth ok'" testuser && echo "PASS: old password works" || echo "FAIL: old password rejected"
# Note: this is a basic smoke test. When run as root (typical in Docker),
# `su` does not actually verify the password — it succeeds unconditionally.
# For real PAM password verification, run this test as a non-root user or
# use an expect-based harness.
echo "oldpassword" | su -c "echo 'auth ok'" testuser && echo "PASS: old password works (best-effort, see comment)" || echo "FAIL: old password rejected"
echo "=== Changing password via shadow-rs passwd ==="
# This requires PAM feature — skip if not compiled with PAM
@@ -21,4 +29,7 @@ else
echo "SKIP: passwd not compiled with stdin support"
fi
echo "=== Cleanup ==="
userdel -r testuser 2>/dev/null || true
echo "=== Done ==="