mirror of
https://github.com/uutils/shadow.git
synced 2026-10-16 02:48:14 -07:00
* usermod: add and remove subordinate id ranges GNU usermod has -v/--add-subuids, -V/--del-subuids, -w/--add-subgids and -W/--del-subgids; ours had none of them, which came to light while writing the tests for newuidmap. The range helpers live in shadow_core::subid so whatever next touches /etc/subuid reads the same file the same way. The behaviour was established by running the GNU tool: a range the user already holds is not added twice; a removal trims an entry it overlaps, splits one it cuts through the middle of, deletes one it covers, and ignores a range never held; nothing checks other users' entries, since an administrator may share a range on purpose; and an invalid range -- reversed, not two numbers, above the 32-bit ids the kernel maps -- is exit 3 with the GNU wording, before any file is written. Entries are keyed by the login name given on the command line and are not renamed by -l, as GNU's are not: a range is a grant to a name. -V is GNU's letter for the deletion, so clap's own -V for --version had to go; --version stays. * expiry: check and enforce password expiration expiry is the twenty-eighth tool and the last the Scope table lists as in scope. It judges the caller's own /etc/shadow line as login would: silent when all is well, a warning inside warn_days, a forced change through PAM when the password has expired, a refusal when the account has. Shell profiles run it so a session that did not come through login -- a display manager, an ssh key -- still meets the aging policy. The rules are in shadow_core::shadow::Aging, one function over the aging fields with checked arithmetic, since every input is read from a file anyone who can write /etc/shadow chooses. Its order is login's: account expiry first, then a last change of 0 or an aged-out password, then the warning window, with a maximum age of 10000 or more meaning never as chage -l prints it. A locked password is a policy of its own and is not reported. The GNU tool is setgid shadow -- enough to read the file, no more -- and the change goes through PAM as the caller. The per-tool install ships it the same way, a new install class. The single setuid binary keeps euid 0 for it, having nothing narrower to offer. Under --prefix it reports and never changes anything, since a PAM change would act on this system's account of the same name; that is also what lets the required-change path be asserted in the integration tests without a prompt, while the e2e suite drives the prompt itself on a real user under a pty and checks the hash changed. groupmems is not coming. Debian 13 and Fedora do not ship it, and on Ubuntu 24.04 every action fails against PAM's default for want of a /etc/pam.d/groupmems: a tool one distribution ships and none can use. The Scope table says so. Verified: make check clean with the unprivileged run; 935 tests on alpine and 950 on fedora with PAM; 410 e2e assertions against a real install; 59 GNU comparisons; 35 arm64 assertions under emulation. * shadow-core: stop asking nscd to invalidate a shadow cache it never had Every passwd, chage and chpasswd ended with `nscd -i shadow`, and on a host running nscd that printed "nscd: 'shadow' is not a known database" on the user's terminal each time. nscd caches passwd, group, hosts, services and netgroup; the passwd entries it holds carry the `x` placeholder, so a password change leaves nothing of nscd's stale. Only the databases nscd knows are passed to it now. sss_cache still gets -U for a shadow change, which is right: sssd does cache what a password change affects.
1484 lines
62 KiB
Bash
Executable File
1484 lines
62 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# shadow-rs end-to-end deployment tests
|
|
#
|
|
# Runs ~100 assertions validating that shadow-rs works as a drop-in
|
|
# replacement for GNU shadow-utils when installed system-wide.
|
|
#
|
|
# Usage:
|
|
# docker compose run --rm e2e # run all tests
|
|
# docker compose run --rm e2e bash # debug interactively
|
|
#
|
|
# Requires: root (for user/group management), expect, nscd, ansible-core
|
|
|
|
set -uo pipefail
|
|
|
|
# ── Test framework ──────────────────────────────────────────────────
|
|
|
|
PASS=0
|
|
FAIL=0
|
|
SECTION=""
|
|
|
|
RED='\033[0;31m'
|
|
GREEN='\033[0;32m'
|
|
YELLOW='\033[1;33m'
|
|
BLUE='\033[0;34m'
|
|
NC='\033[0m'
|
|
|
|
section() {
|
|
SECTION="$1"
|
|
echo -e "\n${BLUE}── $1 ──${NC}"
|
|
}
|
|
|
|
assert_ok() {
|
|
local desc="$1"
|
|
shift
|
|
if "$@" >/dev/null 2>&1; then
|
|
echo -e " ${GREEN}✓${NC} $desc"
|
|
PASS=$((PASS + 1))
|
|
else
|
|
echo -e " ${RED}✗${NC} $desc (command: $1)"
|
|
FAIL=$((FAIL + 1))
|
|
fi
|
|
}
|
|
|
|
assert_fail() {
|
|
local desc="$1"
|
|
shift
|
|
if "$@" >/dev/null 2>&1; then
|
|
echo -e " ${RED}✗${NC} $desc (expected failure, got success)"
|
|
FAIL=$((FAIL + 1))
|
|
else
|
|
echo -e " ${GREEN}✓${NC} $desc"
|
|
PASS=$((PASS + 1))
|
|
fi
|
|
}
|
|
|
|
assert_contains() {
|
|
local desc="$1"
|
|
local pattern="$2"
|
|
shift 2
|
|
local output
|
|
if output=$("$@" 2>&1) && echo "$output" | grep -q "$pattern"; then
|
|
echo -e " ${GREEN}✓${NC} $desc"
|
|
PASS=$((PASS + 1))
|
|
else
|
|
echo -e " ${RED}✗${NC} $desc (pattern '$pattern' not found in output)"
|
|
FAIL=$((FAIL + 1))
|
|
fi
|
|
}
|
|
|
|
assert_file_contains() {
|
|
local desc="$1"
|
|
local file="$2"
|
|
local pattern="$3"
|
|
if grep -q "$pattern" "$file" 2>/dev/null; then
|
|
echo -e " ${GREEN}✓${NC} $desc"
|
|
PASS=$((PASS + 1))
|
|
else
|
|
echo -e " ${RED}✗${NC} $desc (pattern '$pattern' not in $file)"
|
|
FAIL=$((FAIL + 1))
|
|
fi
|
|
}
|
|
|
|
assert_file_not_contains() {
|
|
local desc="$1"
|
|
local file="$2"
|
|
local pattern="$3"
|
|
if grep -q "$pattern" "$file" 2>/dev/null; then
|
|
echo -e " ${RED}✗${NC} $desc (pattern '$pattern' found in $file but shouldn't be)"
|
|
FAIL=$((FAIL + 1))
|
|
else
|
|
echo -e " ${GREEN}✓${NC} $desc"
|
|
PASS=$((PASS + 1))
|
|
fi
|
|
}
|
|
|
|
# Helper: hash a plaintext password for chpasswd -e
|
|
hash_password() {
|
|
openssl passwd -6 "$1"
|
|
}
|
|
|
|
# ── TOOLS list ──────────────────────────────────────────────────────
|
|
|
|
TOOLS="passwd pwck useradd userdel usermod chpasswd chgpasswd newusers chage groupadd groupdel groupmod gpasswd grpck chfn chsh newgrp sg vipw vigr pwconv pwunconv grpconv grpunconv login newuidmap newgidmap expiry"
|
|
SETUID_TOOLS="passwd chfn chsh newgrp gpasswd sg newuidmap newgidmap"
|
|
|
|
# The tools an unprivileged user runs are installed in bin, the rest in sbin,
|
|
# which is the split the GNU package uses: sbin is not on a normal user's
|
|
# PATH, so `passwd` there would be "command not found".
|
|
USER_TOOLS="$SETUID_TOOLS chage login expiry"
|
|
BINDIR="/usr/sbin"
|
|
USER_BINDIR="/usr/bin"
|
|
|
|
# Where a given tool's symlink was installed.
|
|
tool_path() {
|
|
case " $USER_TOOLS " in
|
|
*" $1 "*) echo "/usr/bin/$1" ;;
|
|
*) echo "/usr/sbin/$1" ;;
|
|
esac
|
|
}
|
|
|
|
# ── Preflight ───────────────────────────────────────────────────────
|
|
|
|
preflight() {
|
|
section "Preflight checks"
|
|
|
|
assert_ok "shadow-rs binary exists" test -x "$BINDIR/shadow-rs"
|
|
assert_ok "shadow-rs --list succeeds" "$BINDIR/shadow-rs" --list
|
|
|
|
for tool in $TOOLS; do
|
|
local path
|
|
path=$(tool_path "$tool")
|
|
assert_ok "symlink exists: $path" test -L "$path"
|
|
assert_ok "symlink $tool resolves to shadow-rs" \
|
|
bash -c "readlink -f '$path' | grep -q 'shadow-rs'"
|
|
done
|
|
|
|
# An unprivileged user must be able to run their own tools by name.
|
|
for tool in $USER_TOOLS; do
|
|
assert_ok "$tool is on an unprivileged user's PATH" \
|
|
su -s /bin/bash testrunner -c "command -v $tool >/dev/null"
|
|
done
|
|
}
|
|
|
|
# ── Symlink dispatch ────────────────────────────────────────────────
|
|
|
|
test_symlink_dispatch() {
|
|
section "Symlink dispatch (argv[0])"
|
|
|
|
for tool in $TOOLS; do
|
|
assert_ok "$tool --help via symlink" "$(tool_path "$tool")" --help
|
|
done
|
|
}
|
|
|
|
# ── Multicall dispatch ──────────────────────────────────────────────
|
|
|
|
test_multicall_dispatch() {
|
|
section "Multicall dispatch (shadow-rs <tool>)"
|
|
|
|
for tool in $TOOLS; do
|
|
assert_ok "shadow-rs $tool --help" "$BINDIR/shadow-rs" "$tool" --help
|
|
done
|
|
}
|
|
|
|
# ── Setuid ──────────────────────────────────────────────────────────
|
|
|
|
test_setuid() {
|
|
section "Setuid bits"
|
|
|
|
# Check setuid on the target binary (symlinks always show 777)
|
|
local target_perms
|
|
target_perms=$(stat -L -c '%a' "$BINDIR/shadow-rs" 2>/dev/null || echo "0")
|
|
if [[ "$target_perms" == "4755" ]]; then
|
|
echo -e " ${GREEN}✓${NC} shadow-rs binary has setuid bit (4755)"
|
|
PASS=$((PASS + 1))
|
|
else
|
|
echo -e " ${RED}✗${NC} shadow-rs binary expected 4755, got $target_perms"
|
|
FAIL=$((FAIL + 1))
|
|
fi
|
|
|
|
# Verify each setuid tool symlink resolves to the setuid binary
|
|
for tool in $SETUID_TOOLS; do
|
|
local resolved_perms
|
|
resolved_perms=$(stat -L -c '%a' "$(tool_path "$tool")" 2>/dev/null || echo "0")
|
|
if [[ "$resolved_perms" == "4755" ]]; then
|
|
echo -e " ${GREEN}✓${NC} $tool resolves to setuid binary (4755)"
|
|
PASS=$((PASS + 1))
|
|
else
|
|
echo -e " ${RED}✗${NC} $tool expected 4755 (via symlink), got $resolved_perms"
|
|
FAIL=$((FAIL + 1))
|
|
fi
|
|
done
|
|
|
|
# passwd must be built with the `pam` cargo feature, otherwise the
|
|
# interactive change path is compiled out and the tool can only report and
|
|
# lock accounts — it cannot actually change a password.
|
|
# chfn and chsh authenticate the caller through PAM before applying a
|
|
# change, so a build without the feature refuses every non-root use.
|
|
for tool in passwd chfn chsh; do
|
|
assert_ok "$tool is linked against PAM" \
|
|
sh -c "ldd $(tool_path "$tool") | grep -q libpam"
|
|
done
|
|
|
|
# Non-root user should be able to run passwd -S on themselves
|
|
assert_ok "testrunner can run passwd -S" \
|
|
su -s /bin/bash testrunner -c "$USER_BINDIR/passwd -S testrunner"
|
|
|
|
# Non-root user should NOT be able to change another user's password
|
|
assert_fail "testrunner cannot passwd root" \
|
|
su -s /bin/bash testrunner -c "echo 'root:hacked' | $BINDIR/chpasswd -e"
|
|
|
|
# The binary is setuid for the four self-service tools only; every other
|
|
# applet must run with the caller's own privileges. An unprivileged
|
|
# `pwck -s` or `grpck -s` must neither rewrite a file nor change its owner.
|
|
local passwd_before group_before
|
|
passwd_before=$(stat -c '%i %U:%G' /etc/passwd)
|
|
group_before=$(stat -c '%i %U:%G' /etc/group)
|
|
assert_fail "testrunner cannot run shadow-rs pwck -s" \
|
|
su -s /bin/bash testrunner -c "$BINDIR/shadow-rs pwck -s"
|
|
assert_fail "testrunner cannot run pwck -s through the symlink" \
|
|
su -s /bin/bash testrunner -c "$BINDIR/pwck -s"
|
|
assert_fail "testrunner cannot run grpck -s" \
|
|
su -s /bin/bash testrunner -c "$BINDIR/grpck -s"
|
|
assert_ok "/etc/passwd untouched by unprivileged pwck -s" \
|
|
test "$(stat -c '%i %U:%G' /etc/passwd)" = "$passwd_before"
|
|
assert_ok "/etc/group untouched by unprivileged grpck -s" \
|
|
test "$(stat -c '%i %U:%G' /etc/group)" = "$group_before"
|
|
assert_ok "/etc/shadow is root:shadow 0640" \
|
|
test "$(stat -c '%U:%G %a' /etc/shadow)" = "root:shadow 640"
|
|
}
|
|
|
|
# ── User lifecycle ──────────────────────────────────────────────────
|
|
|
|
test_user_lifecycle() {
|
|
section "User lifecycle (useradd → chpasswd → usermod → pwck → userdel)"
|
|
|
|
# Clean up from any previous failed run
|
|
userdel -r lifecycle_user 2>/dev/null || true
|
|
groupdel lifecycle_grp 2>/dev/null || true
|
|
|
|
# Create user
|
|
assert_ok "useradd -m -s /bin/bash lifecycle_user" \
|
|
useradd -m -s /bin/bash lifecycle_user
|
|
|
|
assert_file_contains "lifecycle_user in /etc/passwd" \
|
|
/etc/passwd "^lifecycle_user:"
|
|
|
|
assert_file_contains "lifecycle_user in /etc/shadow" \
|
|
/etc/shadow "^lifecycle_user:"
|
|
|
|
assert_ok "home directory created" test -d /home/lifecycle_user
|
|
|
|
# Set password via chpasswd -e (pre-hashed)
|
|
local hashed
|
|
hashed=$(hash_password "TestPass123")
|
|
assert_ok "chpasswd -e sets password" \
|
|
bash -c "echo 'lifecycle_user:$hashed' | chpasswd -e"
|
|
|
|
# Verify shadow entry has a hash (not ! or *)
|
|
assert_file_not_contains "shadow has real hash (not locked)" \
|
|
/etc/shadow '^lifecycle_user:[!*]:'
|
|
|
|
# Modify user
|
|
assert_ok "usermod -c 'Lifecycle Test' lifecycle_user" \
|
|
usermod -c "Lifecycle Test" lifecycle_user
|
|
|
|
assert_file_contains "GECOS updated" \
|
|
/etc/passwd "lifecycle_user:.*:Lifecycle Test:"
|
|
|
|
assert_ok "usermod -s /bin/sh lifecycle_user" \
|
|
usermod -s /bin/sh lifecycle_user
|
|
|
|
assert_file_contains "shell updated to /bin/sh" \
|
|
/etc/passwd "lifecycle_user:.*:/bin/sh$"
|
|
|
|
# Add supplementary group
|
|
groupadd lifecycle_grp 2>/dev/null || true
|
|
assert_ok "usermod -aG lifecycle_grp lifecycle_user" \
|
|
usermod -aG lifecycle_grp lifecycle_user
|
|
|
|
assert_contains "user in supplementary group" "lifecycle_user" \
|
|
grep "^lifecycle_grp:" /etc/group
|
|
|
|
# Consistency check (exit 2 = warnings about system accounts, acceptable)
|
|
assert_ok "pwck -r passes" bash -c 'rc=$(pwck -r >/dev/null 2>&1; echo $?); [ "$rc" -le 2 ]'
|
|
|
|
# Lock and unlock password
|
|
assert_ok "passwd -l lifecycle_user" passwd -l lifecycle_user
|
|
assert_file_contains "password locked (! prefix)" \
|
|
/etc/shadow '^lifecycle_user:!'
|
|
# Every rewrite must leave the file as the distribution installed it;
|
|
# unix_chkpwd and friends are sgid shadow and rely on it.
|
|
assert_ok "/etc/shadow is still root:shadow 0640 after passwd -l" \
|
|
test "$(stat -c '%U:%G %a' /etc/shadow)" = "root:shadow 640"
|
|
|
|
# Locking twice must leave one marker: a second would need a second
|
|
# unlock, and `passwd -u` refuses to unlock an account that would stay
|
|
# locked, so the tool could not undo its own second lock.
|
|
assert_ok "passwd -l lifecycle_user again" passwd -l lifecycle_user
|
|
assert_ok "a second lock adds no second marker" \
|
|
bash -c "! grep -q '^lifecycle_user:!!' /etc/shadow"
|
|
|
|
assert_ok "passwd -u lifecycle_user" passwd -u lifecycle_user
|
|
assert_file_not_contains "password unlocked (no ! prefix)" \
|
|
/etc/shadow '^lifecycle_user:!'
|
|
|
|
# Delete user
|
|
assert_ok "userdel -r lifecycle_user" userdel -r lifecycle_user
|
|
assert_file_not_contains "user removed from /etc/passwd" \
|
|
/etc/passwd "^lifecycle_user:"
|
|
assert_file_not_contains "user removed from /etc/shadow" \
|
|
/etc/shadow "^lifecycle_user:"
|
|
|
|
# Clean up group
|
|
groupdel lifecycle_grp 2>/dev/null || true
|
|
}
|
|
|
|
# ── Group lifecycle ─────────────────────────────────────────────────
|
|
|
|
test_group_lifecycle() {
|
|
section "Group lifecycle (groupadd → gpasswd → groupmod → groupdel → grpck)"
|
|
|
|
# Clean up from any previous failed run
|
|
groupdel lifecycle_testgrp 2>/dev/null || true
|
|
groupdel lifecycle_renamed 2>/dev/null || true
|
|
|
|
# Create group
|
|
assert_ok "groupadd lifecycle_testgrp" groupadd lifecycle_testgrp
|
|
|
|
assert_file_contains "group in /etc/group" \
|
|
/etc/group "^lifecycle_testgrp:"
|
|
|
|
assert_ok "gpasswd -a root lifecycle_testgrp" \
|
|
gpasswd -a root lifecycle_testgrp
|
|
assert_file_contains "gpasswd added root to group" \
|
|
/etc/group "^lifecycle_testgrp:.*root"
|
|
|
|
# Modify group name
|
|
assert_ok "groupmod -n lifecycle_renamed lifecycle_testgrp" \
|
|
groupmod -n lifecycle_renamed lifecycle_testgrp
|
|
|
|
assert_file_contains "renamed group in /etc/group" \
|
|
/etc/group "^lifecycle_renamed:"
|
|
assert_file_not_contains "old name gone from /etc/group" \
|
|
/etc/group "^lifecycle_testgrp:"
|
|
|
|
# Consistency check
|
|
assert_ok "grpck -r passes" grpck -r
|
|
|
|
# Delete group
|
|
assert_ok "groupdel lifecycle_renamed" groupdel lifecycle_renamed
|
|
assert_file_not_contains "group removed from /etc/group" \
|
|
/etc/group "^lifecycle_renamed:"
|
|
}
|
|
|
|
# ── Individual tool tests ───────────────────────────────────────────
|
|
|
|
test_individual_tools() {
|
|
section "Individual tool tests"
|
|
|
|
# Set up test user for tool-specific tests
|
|
userdel -r tooltest_user 2>/dev/null || true
|
|
assert_ok "useradd -m -s /bin/bash tooltest_user" \
|
|
useradd -m -s /bin/bash tooltest_user
|
|
local hashed
|
|
hashed=$(hash_password "ToolPass123")
|
|
assert_ok "chpasswd -e sets tooltest_user password" \
|
|
bash -c "echo 'tooltest_user:$hashed' | chpasswd -e"
|
|
|
|
# chage: set and query password aging
|
|
assert_ok "chage -l tooltest_user" chage -l tooltest_user
|
|
assert_ok "chage -M 90 tooltest_user" chage -M 90 tooltest_user
|
|
assert_contains "max days is 90" "90" chage -l tooltest_user
|
|
|
|
# chfn: change GECOS
|
|
assert_ok "chfn -f 'Tool Test User' tooltest_user" \
|
|
chfn -f "Tool Test User" tooltest_user
|
|
assert_file_contains "GECOS updated by chfn" \
|
|
/etc/passwd "tooltest_user:.*:Tool Test User"
|
|
|
|
# chsh: change shell
|
|
assert_ok "chsh -s /bin/sh tooltest_user" chsh -s /bin/sh tooltest_user
|
|
assert_file_contains "shell changed by chsh" \
|
|
/etc/passwd "tooltest_user:.*:/bin/sh$"
|
|
|
|
# chpasswd -e: batch password change with pre-hashed
|
|
local newhash
|
|
newhash=$(hash_password "NewPass456")
|
|
assert_ok "chpasswd -e batch mode" \
|
|
bash -c "echo 'tooltest_user:$newhash' | chpasswd -e"
|
|
|
|
# passwd -S: status
|
|
assert_ok "passwd -S tooltest_user" passwd -S tooltest_user
|
|
|
|
# pwck/grpck: read-only checks (exit 2 = warnings, acceptable)
|
|
assert_ok "pwck -r" bash -c 'rc=$(pwck -r >/dev/null 2>&1; echo $?); [ "$rc" -le 2 ]'
|
|
assert_ok "grpck -r" bash -c 'rc=$(grpck -r >/dev/null 2>&1; echo $?); [ "$rc" -le 2 ]'
|
|
|
|
# Clean up
|
|
userdel -r tooltest_user 2>/dev/null || true
|
|
}
|
|
|
|
# ── PAM authentication ──────────────────────────────────────────────
|
|
|
|
# Authenticate as $1 with password $2 through `su` run by testrunner. Root's
|
|
# own `su` never asks for a password (pam_rootok), so an unprivileged caller is
|
|
# the only way to make PAM actually verify one. Exit 0 = authenticated.
|
|
su_as_testrunner() {
|
|
expect -c "
|
|
set timeout 15
|
|
log_user 0
|
|
spawn su -s /bin/bash testrunner -c {su -s /bin/bash -c id $1}
|
|
expect {
|
|
-nocase {password:} { send \"$2\r\"; exp_continue }
|
|
{uid=} { exit 0 }
|
|
-nocase {failure} { exit 1 }
|
|
eof { exit 2 }
|
|
timeout { exit 3 }
|
|
}
|
|
"
|
|
}
|
|
|
|
# $1 changes their own password from $2 to $3 with our passwd, as themselves.
|
|
# Runs under `su -c`, which has no controlling terminal, so the prompts arrive
|
|
# on stdin/stderr — the path the conversation function must handle.
|
|
change_own_password() {
|
|
expect -c "
|
|
set timeout 20
|
|
log_user 0
|
|
spawn su -s /bin/bash $1 -c $USER_BINDIR/passwd
|
|
expect {
|
|
-nocase -re {current.*password:|\\(current\\).*:} { send \"$2\r\"; exp_continue }
|
|
-nocase -re {new.*password:} { send \"$3\r\"; exp_continue }
|
|
-nocase -re {(retype|again).*:} { send \"$3\r\"; exp_continue }
|
|
-nocase {failure} { exit 10 }
|
|
eof { catch wait result; exit [lindex \$result 3] }
|
|
timeout { exit 99 }
|
|
}
|
|
"
|
|
}
|
|
|
|
# root sets $1's password to $2 with our passwd (no current password asked).
|
|
set_password_as_root() {
|
|
expect -c "
|
|
set timeout 20
|
|
log_user 0
|
|
spawn $USER_BINDIR/passwd $1
|
|
expect {
|
|
-nocase -re {new.*password:} { send \"$2\r\"; exp_continue }
|
|
-nocase -re {(retype|again).*:} { send \"$2\r\"; exp_continue }
|
|
eof { catch wait result; exit [lindex \$result 3] }
|
|
timeout { exit 99 }
|
|
}
|
|
"
|
|
}
|
|
|
|
test_pam_auth() {
|
|
section "PAM authentication"
|
|
|
|
# Create PAM test user with pre-hashed password
|
|
userdel -r pamtest_user 2>/dev/null || true
|
|
assert_ok "useradd -m -s /bin/bash pamtest_user" \
|
|
useradd -m -s /bin/bash pamtest_user
|
|
local hashed
|
|
hashed=$(hash_password "PamPass789")
|
|
assert_ok "chpasswd -e sets pamtest_user password" \
|
|
bash -c "echo 'pamtest_user:$hashed' | chpasswd -e"
|
|
|
|
assert_ok "known password authenticates through PAM" \
|
|
su_as_testrunner pamtest_user PamPass789
|
|
assert_fail "wrong password is rejected" \
|
|
su_as_testrunner pamtest_user WrongPass000
|
|
|
|
# The reason passwd is setuid: a user changes their own password. This is
|
|
# pam_unix end to end — authenticate with the current password through
|
|
# the unprivileged helper, then rewrite /etc/shadow with the privilege the
|
|
# setuid bit provides.
|
|
assert_ok "pamtest_user changes own password with passwd" \
|
|
change_own_password pamtest_user PamPass789 NewPass456
|
|
assert_ok "new password authenticates" \
|
|
su_as_testrunner pamtest_user NewPass456
|
|
assert_fail "old password no longer authenticates" \
|
|
su_as_testrunner pamtest_user PamPass789
|
|
assert_ok "/etc/shadow is still root:shadow 0640 after a PAM change" \
|
|
test "$(stat -c '%U:%G %a' /etc/shadow)" = "root:shadow 640"
|
|
|
|
assert_ok "root sets pamtest_user's password with passwd" \
|
|
set_password_as_root pamtest_user RootSet321
|
|
assert_ok "root-set password authenticates" \
|
|
su_as_testrunner pamtest_user RootSet321
|
|
|
|
# Clean up
|
|
userdel -r pamtest_user 2>/dev/null || true
|
|
}
|
|
|
|
# ── Self-service tools: chfn and chsh ───────────────────────────────
|
|
|
|
# $1 runs chsh interactively as themselves, answering the shell prompt with $2
|
|
# and any password prompt with $3. Exits with chsh's own status.
|
|
chsh_interactive() {
|
|
expect -c "
|
|
set timeout 20
|
|
log_user 0
|
|
spawn su -s /bin/bash $1 -c $USER_BINDIR/chsh
|
|
expect {
|
|
-nocase -re {login shell.*:} { send \"$2\r\"; exp_continue }
|
|
-nocase -re {password:} { send \"$3\r\"; exp_continue }
|
|
eof { catch wait result; exit [lindex \$result 3] }
|
|
timeout { exit 99 }
|
|
}
|
|
"
|
|
}
|
|
|
|
# $1 runs chfn interactively as themselves, answering the room prompt with $2,
|
|
# every other field with ENTER, and any password prompt with $3.
|
|
chfn_interactive() {
|
|
expect -c "
|
|
set timeout 20
|
|
log_user 0
|
|
spawn su -s /bin/bash $1 -c $USER_BINDIR/chfn
|
|
expect {
|
|
-nocase -re {room number.*:} { send \"$2\r\"; exp_continue }
|
|
-nocase -re {(full name|work phone|home phone).*:} { send \"\r\"; exp_continue }
|
|
-nocase -re {password:} { send \"$3\r\"; exp_continue }
|
|
eof { catch wait result; exit [lindex \$result 3] }
|
|
timeout { exit 99 }
|
|
}
|
|
"
|
|
}
|
|
|
|
# $1 asks chsh for shell $2 and must be refused *before* any password prompt:
|
|
# the value is checked first, so a rejected shell never costs a password.
|
|
# Exit 0 only if chsh failed and never prompted. $3 is the expected message.
|
|
chsh_refused_without_prompt() {
|
|
expect -c "
|
|
set timeout 20
|
|
log_user 0
|
|
spawn su -s /bin/bash $1 -c {$USER_BINDIR/chsh -s $2}
|
|
expect {
|
|
-nocase -re {password:} { exit 20 }
|
|
-nocase {$3} { exp_continue }
|
|
eof { catch wait result;
|
|
if {[lindex \$result 3] == 0} { exit 21 } else { exit 0 } }
|
|
timeout { exit 99 }
|
|
}
|
|
"
|
|
}
|
|
|
|
# Same, but exit 0 only if the message does NOT appear.
|
|
chsh_message_absent() {
|
|
expect -c "
|
|
set timeout 20
|
|
log_user 0
|
|
spawn su -s /bin/bash $1 -c {$USER_BINDIR/chsh -s $2}
|
|
expect {
|
|
-nocase {$3} { exit 22 }
|
|
eof { exit 0 }
|
|
timeout { exit 99 }
|
|
}
|
|
"
|
|
}
|
|
|
|
test_self_service() {
|
|
section "Self-service tools (chfn, chsh)"
|
|
|
|
userdel -r selftest_user 2>/dev/null || true
|
|
assert_ok "useradd -m -s /bin/bash selftest_user" \
|
|
useradd -m -s /bin/bash selftest_user
|
|
local hashed
|
|
hashed=$(hash_password "SelfPass123")
|
|
assert_ok "chpasswd -e sets selftest_user password" \
|
|
bash -c "echo 'selftest_user:$hashed' | chpasswd -e"
|
|
|
|
grep -q '^/bin/sh$' /etc/shells 2>/dev/null || echo /bin/sh >>/etc/shells
|
|
grep -q '^/bin/bash$' /etc/shells 2>/dev/null || echo /bin/bash >>/etc/shells
|
|
|
|
# A user changes their own shell with no -s: chsh prompts, PAM verifies.
|
|
assert_ok "selftest_user changes own shell interactively" \
|
|
chsh_interactive selftest_user /bin/sh SelfPass123
|
|
assert_file_contains "interactive chsh wrote the new shell" \
|
|
/etc/passwd "selftest_user:.*:/bin/sh$"
|
|
|
|
# Pressing ENTER keeps the current value and is not an error.
|
|
assert_ok "empty answer keeps the current shell" \
|
|
chsh_interactive selftest_user "" SelfPass123
|
|
assert_file_contains "shell unchanged after an empty answer" \
|
|
/etc/passwd "selftest_user:.*:/bin/sh$"
|
|
|
|
# chsh is setuid-root, so its existence check sees what root sees. A
|
|
# non-root caller must get the same answer for an unlisted path whether or
|
|
# not it exists, or the tool becomes a filesystem oracle.
|
|
local secret=/root/.chsh-oracle-probe
|
|
: >"$secret"
|
|
chmod 600 "$secret"
|
|
assert_ok "unlisted existing path under /root is refused as unlisted" \
|
|
chsh_refused_without_prompt selftest_user "$secret" "is not listed"
|
|
assert_ok "unlisted missing path under /root gives the same answer" \
|
|
chsh_refused_without_prompt selftest_user /root/.chsh-absent "is not listed"
|
|
assert_ok "existence of a /root path is never disclosed" \
|
|
chsh_message_absent selftest_user "$secret" "does not exist"
|
|
rm -f "$secret"
|
|
|
|
# An empty shell field is the system default, not a missing program.
|
|
assert_ok "root may set the empty shell" chsh -s "" selftest_user
|
|
assert_file_contains "empty shell field written" \
|
|
/etc/passwd "^selftest_user:.*:/home/selftest_user:$"
|
|
assert_ok "chsh -s /bin/bash restores a shell" chsh -s /bin/bash selftest_user
|
|
|
|
# chfn with no field option prompts for each field the caller may change.
|
|
assert_ok "CHFN_RESTRICT=rwh in login.defs" \
|
|
bash -c "sed -i '/^CHFN_RESTRICT/d' /etc/login.defs; echo 'CHFN_RESTRICT rwh' >>/etc/login.defs"
|
|
assert_ok "selftest_user changes own room interactively" \
|
|
chfn_interactive selftest_user B-217 SelfPass123
|
|
assert_file_contains "interactive chfn wrote the room number" \
|
|
/etc/passwd "selftest_user:.*:,B-217,"
|
|
|
|
# 'f' is absent from CHFN_RESTRICT, so the full name is not the caller's
|
|
# to change — and the refusal must arrive without a password prompt.
|
|
assert_fail "selftest_user may not change the full name under CHFN_RESTRICT=rwh" \
|
|
su -s /bin/bash selftest_user -c "$USER_BINDIR/chfn -f Nope </dev/null"
|
|
assert_ok "root may still change the full name" \
|
|
chfn -f "Self Test User" selftest_user
|
|
assert_file_contains "root-set full name written" \
|
|
/etc/passwd "selftest_user:.*:Self Test User,"
|
|
|
|
userdel -r selftest_user 2>/dev/null || true
|
|
}
|
|
|
|
# ── Aging fields, batch input and newgrp ────────────────────────────
|
|
|
|
# Print the value column of one `chage -l` line, e.g. aging_field 2 alice
|
|
# for "Password expires".
|
|
aging_field() {
|
|
chage -l "$2" | sed -n "$1p" | sed 's/.*: //'
|
|
}
|
|
|
|
test_aging_and_input() {
|
|
section "Aging fields, batch input, newgrp"
|
|
|
|
userdel -r aging_user 2>/dev/null || true
|
|
assert_ok "useradd -m aging_user" useradd -m aging_user
|
|
|
|
# A last-change day of 0 is `passwd -e`'s marker, not a date, and it makes
|
|
# both derived dates meaningless too.
|
|
assert_ok "chage -d 0 aging_user" chage -d 0 aging_user
|
|
assert_ok "last change reports 'password must be changed'" \
|
|
test "$(aging_field 1 aging_user)" = "password must be changed"
|
|
assert_ok "password expiry reports 'password must be changed'" \
|
|
test "$(aging_field 2 aging_user)" = "password must be changed"
|
|
assert_ok "password inactive reports 'password must be changed'" \
|
|
test "$(aging_field 3 aging_user)" = "password must be changed"
|
|
|
|
# The threshold above which a maximum age means "no expiry" is 10000.
|
|
assert_ok "chage -d 2026-01-01 -M 9999 aging_user" \
|
|
chage -d 2026-01-01 -M 9999 aging_user
|
|
assert_ok "max 9999 gives a real expiry date" \
|
|
test "$(aging_field 2 aging_user)" = "May 18, 2053"
|
|
assert_ok "chage -M 10000 aging_user" chage -M 10000 aging_user
|
|
assert_ok "max 10000 means never" \
|
|
test "$(aging_field 2 aging_user)" = "never"
|
|
|
|
# -1 clears a field; anything else negative is a usage error.
|
|
assert_ok "chage -M -1 clears the maximum" chage -M -1 aging_user
|
|
assert_ok "cleared maximum reads back as -1" \
|
|
test "$(aging_field 6 aging_user)" = "-1"
|
|
assert_fail "chage -M -5 is refused" chage -M -5 aging_user
|
|
assert_fail "chage -d -5 is refused" chage -d -5 aging_user
|
|
|
|
# An unknown login is an ordinary failure (1), not "no shadow file" (15).
|
|
assert_ok "chage -l on an unknown login exits 1" \
|
|
bash -c 'chage -l no-such-user-4b2 >/dev/null 2>&1; [ "$?" -eq 1 ]'
|
|
assert_ok "passwd -S on an unknown login exits 1" \
|
|
bash -c 'passwd -S no-such-user-4b2 >/dev/null 2>&1; [ "$?" -eq 1 ]'
|
|
|
|
# chpasswd takes its default hashing scheme from login.defs, as the rest
|
|
# of the system does; hard-coding one wrote weaker hashes than configured.
|
|
assert_ok "ENCRYPT_METHOD YESCRYPT in login.defs" \
|
|
bash -c "sed -i '/^ENCRYPT_METHOD/d' /etc/login.defs; echo 'ENCRYPT_METHOD YESCRYPT' >>/etc/login.defs"
|
|
assert_ok "chpasswd hashes aging_user" \
|
|
bash -c "echo 'aging_user:BatchPass123' | chpasswd"
|
|
assert_file_contains "chpasswd used yescrypt as configured" \
|
|
/etc/shadow 'aging_user:\$y\$'
|
|
assert_ok "ENCRYPT_METHOD SHA512 in login.defs" \
|
|
bash -c "sed -i 's/^ENCRYPT_METHOD.*/ENCRYPT_METHOD SHA512/' /etc/login.defs"
|
|
assert_ok "chpasswd hashes aging_user again" \
|
|
bash -c "echo 'aging_user:BatchPass456' | chpasswd"
|
|
assert_file_contains "chpasswd followed the changed configuration" \
|
|
/etc/shadow 'aging_user:\$6\$'
|
|
assert_ok "-c overrides the configured default" \
|
|
bash -c "echo 'aging_user:BatchPass789' | chpasswd -c SHA256"
|
|
assert_file_contains "explicit -c wins" /etc/shadow 'aging_user:\$5\$'
|
|
|
|
# Flag combinations chpasswd(8) documents as errors.
|
|
assert_ok "-s without -c is a usage error (exit 2)" \
|
|
bash -c 'echo "aging_user:x" | chpasswd -s 5000 >/dev/null 2>&1; [ "$?" -eq 2 ]'
|
|
assert_ok "-e with -c is a usage error (exit 2)" \
|
|
bash -c 'echo "aging_user:x" | chpasswd -e -c SHA512 >/dev/null 2>&1; [ "$?" -eq 2 ]'
|
|
|
|
# --prefix on chage and chpasswd, so their behaviour is testable without
|
|
# touching the live files.
|
|
local pfx
|
|
pfx=$(mktemp -d)
|
|
mkdir -p "$pfx/etc"
|
|
printf 'pfxuser:x:4000:4000::/home/pfxuser:/bin/sh\n' >"$pfx/etc/passwd"
|
|
printf 'pfxuser:!:19000:0:99999:7:::\n' >"$pfx/etc/shadow"
|
|
assert_ok "chage --prefix reads the prefixed shadow file" \
|
|
chage -P "$pfx" -l pfxuser
|
|
assert_ok "chage --prefix writes the prefixed shadow file" \
|
|
chage -P "$pfx" -M 42 pfxuser
|
|
assert_file_contains "prefixed maximum age written" \
|
|
"$pfx/etc/shadow" 'pfxuser:!:19000:0:42:'
|
|
assert_ok "chpasswd --prefix writes the prefixed shadow file" \
|
|
bash -c "echo 'pfxuser:PrefixPass1' | chpasswd -P '$pfx'"
|
|
assert_ok "the live shadow file was untouched by --prefix" \
|
|
bash -c "! grep -q '^pfxuser:' /etc/shadow"
|
|
rm -rf "$pfx"
|
|
|
|
# newgrp: without '-' the environment and working directory survive; with
|
|
# '-' the shell is a login shell in a reinitialized environment.
|
|
groupadd -f newgrp_grp >/dev/null 2>&1
|
|
usermod -aG newgrp_grp aging_user
|
|
assert_ok "newgrp switches the primary group" \
|
|
bash -c "su -s /bin/bash aging_user -c 'echo id -gn | newgrp newgrp_grp' | grep -qx newgrp_grp"
|
|
assert_ok "newgrp keeps the caller's environment" \
|
|
bash -c "su -s /bin/bash aging_user -c 'MARKER=kept; export MARKER; echo \"echo \\\$MARKER\" | newgrp newgrp_grp' | grep -qx kept"
|
|
assert_ok "newgrp - reinitializes the environment" \
|
|
bash -c "su -s /bin/bash aging_user -c 'MARKER=kept; export MARKER; echo \"echo m=\\\$MARKER\" | newgrp - newgrp_grp' | grep -qx 'm='"
|
|
assert_ok "newgrp - still switches the group" \
|
|
bash -c "su -s /bin/bash aging_user -c 'echo id -gn | newgrp - newgrp_grp' | grep -qx newgrp_grp"
|
|
assert_fail "newgrp rejects a misplaced dash" \
|
|
su -s /bin/bash aging_user -c "newgrp newgrp_grp - </dev/null"
|
|
|
|
groupdel newgrp_grp 2>/dev/null || true
|
|
userdel -r aging_user 2>/dev/null || true
|
|
}
|
|
|
|
# ── Audit logging ───────────────────────────────────────────────────
|
|
|
|
test_audit_logging() {
|
|
section "Audit logging"
|
|
|
|
# The tools write their records straight to /dev/log rather than forking
|
|
# `logger` for each event, so the test needs a daemon listening there.
|
|
rsyslogd -n >/dev/null 2>&1 &
|
|
local rsyslog_pid=$!
|
|
sleep 2
|
|
|
|
if [ ! -S /dev/log ]; then
|
|
echo -e " ${YELLOW}⊘${NC} no /dev/log socket, skipping"
|
|
kill "$rsyslog_pid" 2>/dev/null || true
|
|
return
|
|
fi
|
|
|
|
userdel -r audit_user 2>/dev/null || true
|
|
assert_ok "useradd -m audit_user" useradd -m audit_user
|
|
assert_ok "userdel -r audit_user" userdel -r audit_user
|
|
sleep 1
|
|
|
|
local log=/var/log/auth.log
|
|
[ -f "$log" ] || log=/var/log/syslog
|
|
assert_ok "syslog received the account creation" \
|
|
bash -c "grep -q 'op=ADD_USER.*acct=\"audit_user\".*res=success' $log"
|
|
assert_ok "syslog received the account deletion" \
|
|
bash -c "grep -q 'op=DEL_USER.*acct=\"audit_user\"' $log"
|
|
assert_ok "the record is tagged shadow-rs" \
|
|
bash -c "grep -q 'shadow-rs\[[0-9]*\]:.*op=ADD_USER' $log"
|
|
|
|
kill "$rsyslog_pid" 2>/dev/null || true
|
|
}
|
|
|
|
# ── --root is a real chroot ─────────────────────────────────────────
|
|
|
|
test_root_option() {
|
|
section "--root is a real chroot"
|
|
|
|
# A minimal target tree. The point of --root is that absolute paths read
|
|
# out of the records resolve inside it too, so the home directory must be
|
|
# created here and not on the host.
|
|
local tree
|
|
tree=$(mktemp -d)
|
|
mkdir -p "$tree/etc" "$tree/home" "$tree/etc/skel"
|
|
printf 'root:x:0:0:root:/root:/bin/sh\n' >"$tree/etc/passwd"
|
|
printf 'root:!:19000:0:99999:7:::\n' >"$tree/etc/shadow"
|
|
printf 'root:x:0:\n' >"$tree/etc/group"
|
|
printf 'root:!::\n' >"$tree/etc/gshadow"
|
|
printf 'UID_MIN 1000\nGID_MIN 1000\n' >"$tree/etc/login.defs"
|
|
echo 'from the tree skeleton' >"$tree/etc/skel/.profile"
|
|
|
|
assert_ok "useradd -R creates the account in the tree" \
|
|
useradd -R "$tree" -m rooted_user
|
|
assert_file_contains "the record is in the tree" \
|
|
"$tree/etc/passwd" '^rooted_user:'
|
|
assert_ok "the home is in the tree, not on the host" \
|
|
test -d "$tree/home/rooted_user"
|
|
assert_ok "the skeleton came from inside the tree" \
|
|
bash -c "grep -q 'from the tree skeleton' '$tree/home/rooted_user/.profile'"
|
|
assert_ok "the host was not touched" \
|
|
bash -c "! test -e /home/rooted_user && ! grep -q '^rooted_user:' /etc/passwd"
|
|
|
|
assert_ok "usermod -R edits the account in the tree" \
|
|
usermod -R "$tree" -c 'Rooted User' rooted_user
|
|
assert_file_contains "usermod wrote inside the tree" \
|
|
"$tree/etc/passwd" 'rooted_user:.*Rooted User'
|
|
|
|
assert_ok "groupadd -R adds a group in the tree" groupadd -R "$tree" rooted_grp
|
|
assert_file_contains "the group is in the tree" "$tree/etc/group" '^rooted_grp:'
|
|
assert_ok "groupmod -R renames it" groupmod -R "$tree" -n rooted_grp2 rooted_grp
|
|
assert_ok "groupdel -R removes it" groupdel -R "$tree" rooted_grp2
|
|
assert_ok "the group is gone from the tree" \
|
|
bash -c "! grep -q '^rooted_grp' '$tree/etc/group'"
|
|
assert_ok "no group leaked onto the host" \
|
|
bash -c "! grep -q '^rooted_grp' /etc/group"
|
|
|
|
# pwck and grpck have no --prefix, matching GNU, so --root is the only way
|
|
# to point them at another tree.
|
|
assert_ok "grpck -r reads the tree" grpck -r -R "$tree"
|
|
assert_ok "pwck -r reads the tree" \
|
|
bash -c "rc=\$(pwck -r -R '$tree' >/dev/null 2>&1; echo \$?); [ \"\$rc\" -le 2 ]"
|
|
|
|
assert_ok "userdel -r -R removes the account and its home in the tree" \
|
|
userdel -R "$tree" -r rooted_user
|
|
assert_ok "the home is gone from the tree" \
|
|
bash -c "! test -e '$tree/home/rooted_user'"
|
|
|
|
rm -rf "$tree"
|
|
}
|
|
|
|
# ── gpasswd as a group administrator ────────────────────────────────
|
|
|
|
test_gpasswd_group_admin() {
|
|
section "gpasswd as a group administrator"
|
|
|
|
# The reason gpasswd is setuid: someone who is not root, but is named an
|
|
# administrator of one group, manages that group's membership. Nothing
|
|
# else in this suite exercises a non-root caller of gpasswd, and the
|
|
# multicall binary drops privilege for every applet not on its setuid
|
|
# list -- so leaving gpasswd off that list fails exactly here, and
|
|
# nowhere else.
|
|
userdel -r gp_admin 2>/dev/null || true
|
|
userdel -r gp_member 2>/dev/null || true
|
|
groupdel gp_team 2>/dev/null || true
|
|
|
|
assert_ok "useradd -m gp_admin" useradd -m gp_admin
|
|
assert_ok "useradd -m gp_member" useradd -m gp_member
|
|
assert_ok "groupadd gp_team" groupadd gp_team
|
|
assert_ok "gpasswd -A gp_admin gp_team" gpasswd -A gp_admin gp_team
|
|
assert_file_contains "gp_admin is recorded as an administrator" \
|
|
/etc/gshadow '^gp_team:[^:]*:gp_admin:'
|
|
|
|
assert_ok "an administrator adds a member without being root" \
|
|
su -s /bin/bash gp_admin -c "gpasswd -a gp_member gp_team"
|
|
assert_file_contains "the member is in /etc/group" \
|
|
/etc/group '^gp_team:.*:gp_member'
|
|
assert_file_contains "and in /etc/gshadow" \
|
|
/etc/gshadow '^gp_team:.*:gp_member'
|
|
|
|
assert_fail "a plain member may not change the membership" \
|
|
su -s /bin/bash gp_member -c "gpasswd -a gp_admin gp_team"
|
|
assert_fail "a plain member may not appoint administrators" \
|
|
su -s /bin/bash gp_member -c "gpasswd -A gp_member gp_team"
|
|
assert_fail "a plain member may not use --root" \
|
|
su -s /bin/bash gp_member -c "gpasswd --root / -a gp_member gp_team"
|
|
|
|
# The refusals must change nothing.
|
|
assert_file_contains "the administrator list is unchanged" \
|
|
/etc/gshadow '^gp_team:[^:]*:gp_admin:'
|
|
|
|
groupdel gp_team 2>/dev/null || true
|
|
userdel -r gp_admin 2>/dev/null || true
|
|
userdel -r gp_member 2>/dev/null || true
|
|
}
|
|
|
|
# ── usermod subordinate ids, and expiry ────────────────────────────
|
|
|
|
test_subid_and_expiry() {
|
|
section "usermod -v/-V/-w/-W, and expiry"
|
|
|
|
userdel -r ex_user 2>/dev/null || true
|
|
assert_ok "useradd -m ex_user" useradd -m -s /bin/sh ex_user
|
|
assert_ok "set a password" bash -c "printf 'ex_user:oldpw\n' | chpasswd"
|
|
|
|
assert_ok "usermod -v grants a subuid range" usermod -v 500000-500999 ex_user
|
|
assert_file_contains "the range is in /etc/subuid" /etc/subuid '^ex_user:500000:1000$'
|
|
assert_ok "usermod -V of half of it splits the entry" usermod -V 500000-500499 ex_user
|
|
assert_file_contains "the remainder is what is left" /etc/subuid '^ex_user:500500:500$'
|
|
assert_fail "an invalid range is refused" usermod -v 9-1 ex_user
|
|
|
|
assert_ok "expiry -c is silent on a healthy account" \
|
|
bash -c "out=\$(su -s /bin/sh ex_user -c 'expiry -c'); test -z \"\$out\""
|
|
chage -M 30 -d "$(date -d '-27 days' +%Y-%m-%d)" ex_user
|
|
assert_contains "expiry warns inside the warning window" "Your password will expire in 3 days." \
|
|
su -s /bin/sh ex_user -c 'expiry -c'
|
|
|
|
if command -v python3 >/dev/null 2>&1; then
|
|
chage -M 1 -d 2000-01-01 ex_user
|
|
cat > /tmp/drive_expiry.py <<'PYDRV'
|
|
import os, pty, select, sys, time
|
|
pid, fd = pty.fork()
|
|
if pid == 0:
|
|
os.environ["TERM"] = "dumb"; os.execvp("su", ["su", "-s", "/bin/sh", "ex_user", "-c", "expiry -f; echo RC=$?"])
|
|
buf = b""; t0 = time.time(); i = 0
|
|
steps = [("urrent password:", "oldpw"), ("ew password:", "N3w-Passw0rd-e2e"), ("etype new password:", "N3w-Passw0rd-e2e")]
|
|
while time.time() - t0 < 20 and b"RC=" not in buf:
|
|
r, _, _ = select.select([fd], [], [], 0.2)
|
|
if r:
|
|
try: buf += os.read(fd, 4096)
|
|
except OSError: break
|
|
if i < len(steps) and steps[i][0].encode() in buf:
|
|
os.write(fd, steps[i][1].encode() + b"\n"); buf = buf.replace(steps[i][0].encode(), b"", 1); i += 1
|
|
try: os.kill(pid, 9)
|
|
except ProcessLookupError: pass
|
|
os.waitpid(pid, 0)
|
|
sys.stdout.write(buf.decode(errors="replace"))
|
|
PYDRV
|
|
before=$(grep '^ex_user:' /etc/shadow | cut -d: -f2)
|
|
assert_contains "expiry -f on an expired password forces the change" "RC=0" python3 /tmp/drive_expiry.py
|
|
assert_ok "and the hash changed" \
|
|
bash -c "test \"\$(grep '^ex_user:' /etc/shadow | cut -d: -f2)\" != \"$before\""
|
|
rm -f /tmp/drive_expiry.py
|
|
fi
|
|
|
|
chage -E 2000-01-01 ex_user
|
|
assert_fail "expiry refuses an expired account" su -s /bin/sh ex_user -c 'expiry -c'
|
|
chage -E -1 ex_user
|
|
|
|
userdel -r ex_user 2>/dev/null || true
|
|
sed -i '/^ex_user:/d' /etc/subuid /etc/subgid
|
|
}
|
|
|
|
# ── newuidmap / newgidmap: id maps for a user namespace ────────────
|
|
|
|
test_idmap() {
|
|
section "newuidmap / newgidmap — id maps for a user namespace"
|
|
|
|
userdel -r im_user 2>/dev/null || true
|
|
assert_ok "useradd -M im_user" useradd -M im_user
|
|
printf 'im_user:200000:65536\n' >> /etc/subuid
|
|
printf 'im_user:200000:65536\n' >> /etc/subgid
|
|
|
|
assert_fail "no operands is a usage error" newuidmap
|
|
assert_contains "an operand count that is not triples is reported" "ranges:" \
|
|
bash -c "newuidmap 1 0 200000 2>&1; true"
|
|
assert_contains "a zero count is the overflow message" "subuid overflow detected." \
|
|
bash -c "newuidmap 1 0 200000 0 2>&1; true"
|
|
assert_contains "no such process" "Could not open proc directory" \
|
|
bash -c "newuidmap 4194304 0 0 1 2>&1; true"
|
|
|
|
if ! su -s /bin/sh im_user -c 'unshare -U true' 2>/dev/null; then
|
|
echo -e " ${YELLOW}⊘${NC} user namespaces not available here, skipping the live checks"
|
|
userdel im_user 2>/dev/null || true
|
|
return
|
|
fi
|
|
# A namespace owned by im_user, kept alive while the checks run.
|
|
su -s /bin/sh im_user -c 'unshare -U sh -c "echo \$\$ > /tmp/im_pid; sleep 30"' &
|
|
sleep 1; pid=$(cat /tmp/im_pid)
|
|
uid=$(id -u im_user); gid=$(id -g im_user)
|
|
|
|
assert_contains "a range outside the grant is refused by name" "not allowed" \
|
|
su -s /bin/sh im_user -c "newuidmap $pid 0 300000 10 2>&1; true"
|
|
assert_contains "overlapping ranges are refused by name" "overlap" \
|
|
su -s /bin/sh im_user -c "newuidmap $pid 0 200000 10 5 200100 10 2>&1; true"
|
|
assert_contains "someone else's process is refused" "owned by a different user" \
|
|
su -s /bin/sh im_user -c "newuidmap 1 0 $uid 1 2>&1; true"
|
|
assert_ok "the owner maps their own uid once" \
|
|
su -s /bin/sh im_user -c "newuidmap $pid 0 $uid 1"
|
|
assert_ok "and the kernel shows it" \
|
|
bash -c "test \"\$(tr -s ' ' < /proc/$pid/uid_map | sed 's/^ //')\" = '0 $uid 1'"
|
|
assert_fail "a second write is refused" \
|
|
su -s /bin/sh im_user -c "newuidmap $pid 0 $uid 1"
|
|
echo deny > /proc/$pid/setgroups
|
|
assert_ok "the owner maps their own gid once" \
|
|
su -s /bin/sh im_user -c "newgidmap $pid 0 $gid 1"
|
|
assert_ok "and the kernel shows that too" \
|
|
bash -c "test \"\$(tr -s ' ' < /proc/$pid/gid_map | sed 's/^ //')\" = '0 $gid 1'"
|
|
|
|
kill $pid 2>/dev/null; wait 2>/dev/null
|
|
sed -i '/^im_user:/d' /etc/subuid /etc/subgid
|
|
userdel im_user 2>/dev/null || true
|
|
}
|
|
|
|
# ── login: a session on a terminal ─────────────────────────────────
|
|
|
|
test_login() {
|
|
section "login — a session on a terminal"
|
|
|
|
if ! command -v python3 >/dev/null 2>&1; then
|
|
echo -e " ${YELLOW}⊘${NC} python3 not available, skipping the pty-driven checks"
|
|
return
|
|
fi
|
|
userdel -r lg_e2e 2>/dev/null || true
|
|
assert_ok "useradd -m lg_e2e" useradd -m -s /bin/sh lg_e2e
|
|
assert_ok "set a password" bash -c "printf 'lg_e2e:e2epw\n' | chpasswd"
|
|
|
|
# Drive login the way getty would: on a fresh pty, as session leader,
|
|
# answering each prompt only once it has appeared.
|
|
cat > /tmp/drive_login.py <<'PYDRV'
|
|
import os, pty, select, sys, time
|
|
def run(argv, steps, timeout=40):
|
|
pid, fd = pty.fork()
|
|
if pid == 0:
|
|
os.environ["TERM"] = "dumb"; os.execvp(argv[0], argv)
|
|
buf = b""; i = 0; t0 = time.time(); seen = b""
|
|
while time.time() - t0 < timeout:
|
|
r, _, _ = select.select([fd], [], [], 0.2)
|
|
if r:
|
|
try:
|
|
chunk = os.read(fd, 4096)
|
|
except OSError:
|
|
break
|
|
buf += chunk; seen += chunk
|
|
if i < len(steps) and steps[i][0].encode() in buf:
|
|
os.write(fd, steps[i][1].encode() + b"\n"); buf = b""; i += 1
|
|
if i == len(steps) and steps and steps[-1][1].endswith("exit"):
|
|
done, _ = os.waitpid(pid, os.WNOHANG)
|
|
if done:
|
|
break
|
|
try:
|
|
os.kill(pid, 9)
|
|
except ProcessLookupError:
|
|
pass
|
|
try:
|
|
os.waitpid(pid, 0)
|
|
except ChildProcessError:
|
|
pass
|
|
sys.stdout.write(seen.decode(errors="replace"))
|
|
case = sys.argv[1]
|
|
if case == "autologin":
|
|
run(["login", "-f", "lg_e2e"], [("$ ", "printf 'O''UT %s %s %s\\n' \"$(id -un)\" \"$HOME\" \"$0\"; exit")])
|
|
elif case == "password":
|
|
run(["login", "-H"], [("login: ", "lg_e2e"), ("assword:", "wrong"), ("login: ", "lg_e2e"), ("assword:", "e2epw"), ("$ ", "printf 'O''UT %s\\n' \"$(id -un)\"; exit")])
|
|
elif case == "unknown":
|
|
run(["login", "-H"], [("login: ", "nobody_here_e2e"), ("assword:", "x")], timeout=12)
|
|
PYDRV
|
|
assert_contains "autologin gives a login shell as the user, in the home" \
|
|
"OUT lg_e2e /home/lg_e2e -sh" python3 /tmp/drive_login.py autologin
|
|
assert_contains "a wrong password is refused and the right one accepted" \
|
|
"OUT lg_e2e" python3 /tmp/drive_login.py password
|
|
assert_contains "an unknown user gets the same answer as a wrong password" \
|
|
"Login incorrect" python3 /tmp/drive_login.py unknown
|
|
assert_fail "login refuses to run as a normal user" \
|
|
su -s /bin/bash lg_e2e -c "login -f root"
|
|
assert_fail "login refuses without a terminal" bash -c "login -f root </dev/null"
|
|
|
|
userdel -r lg_e2e 2>/dev/null || true
|
|
rm -f /tmp/drive_login.py
|
|
}
|
|
|
|
# ── pwconv family: shadow round trips ──────────────────────────────
|
|
|
|
test_pwconv_family() {
|
|
section "pwconv / pwunconv / grpconv / grpunconv — shadow round trips"
|
|
|
|
userdel -r pc_alice 2>/dev/null || true
|
|
userdel -r pc_outsider 2>/dev/null || true
|
|
groupdel pc_team 2>/dev/null || true
|
|
assert_ok "useradd -m pc_alice" useradd -m pc_alice
|
|
assert_ok "useradd -m pc_outsider" useradd -m pc_outsider
|
|
assert_ok "groupadd pc_team" groupadd pc_team
|
|
assert_ok "set a group password" bash -c "printf 'pc_team:teampw\n' | chgpasswd"
|
|
assert_ok "set a user password" bash -c "printf 'pc_alice:userpw\n' | chpasswd"
|
|
|
|
grep '^pc_alice:' /etc/shadow | cut -d: -f2 > /tmp/pc_hash.before
|
|
cp -p /etc/shadow /tmp/pc_shadow.before
|
|
|
|
assert_ok "pwunconv" pwunconv
|
|
assert_ok "the shadow file is gone" bash -c "! test -e /etc/shadow"
|
|
assert_ok "the hash moved into /etc/passwd" \
|
|
bash -c "grep '^pc_alice:' /etc/passwd | cut -d: -f2 | diff -q - /tmp/pc_hash.before"
|
|
|
|
assert_ok "pwconv" pwconv
|
|
assert_file_contains "/etc/passwd holds x again" /etc/passwd '^pc_alice:x:'
|
|
assert_ok "the hash is back in /etc/shadow, byte for byte" \
|
|
bash -c "grep '^pc_alice:' /etc/shadow | cut -d: -f2 | diff -q - /tmp/pc_hash.before"
|
|
assert_ok "the recreated shadow file is 0640 root:shadow" \
|
|
bash -c "test \"\$(stat -c '%a %U %G' /etc/shadow)\" = '640 root shadow'"
|
|
# The cross-tool proof: passwd still reads the account's state, and the
|
|
# aging that pwconv set from login.defs is what chage reports.
|
|
assert_contains "passwd -S still sees a set password" " P " passwd -S pc_alice
|
|
assert_ok "pwconv on a consistent system changes nothing" \
|
|
bash -c "cp -p /etc/shadow /tmp/pc_s1 && pwconv && cmp -s /etc/shadow /tmp/pc_s1"
|
|
|
|
# And the group side, with sg as the witness: a non-member must still be
|
|
# able to enter the group with the password after the round trip.
|
|
assert_ok "grpunconv" grpunconv
|
|
assert_ok "the gshadow file is gone" bash -c "! test -e /etc/gshadow"
|
|
assert_ok "grpconv" grpconv
|
|
assert_ok "the recreated gshadow file is 0640 root:shadow" \
|
|
bash -c "test \"\$(stat -c '%a %U %G' /etc/gshadow)\" = '640 root shadow'"
|
|
assert_contains "sg accepts the group password after the round trip" "pc_team" \
|
|
bash -c "echo teampw | su -s /bin/bash pc_outsider -c \"sg pc_team -c 'id -gn'\""
|
|
assert_fail "and still refuses a wrong one" \
|
|
bash -c "echo wrong | su -s /bin/bash pc_outsider -c \"sg pc_team -c 'id -gn'\""
|
|
|
|
assert_fail "an operand is a usage error" pwconv extra
|
|
|
|
userdel -r pc_alice 2>/dev/null || true
|
|
userdel -r pc_outsider 2>/dev/null || true
|
|
groupdel pc_team 2>/dev/null || true
|
|
rm -f /tmp/pc_hash.before /tmp/pc_shadow.before /tmp/pc_s1
|
|
}
|
|
|
|
# ── vipw / vigr: hand edits under the lock ─────────────────────────
|
|
|
|
test_vipw() {
|
|
section "vipw / vigr — hand edits under the lock"
|
|
|
|
userdel -r vp_alice 2>/dev/null || true
|
|
userdel -r vp_racer 2>/dev/null || true
|
|
groupdel vp_team 2>/dev/null || true
|
|
rm -f /etc/passwd.edit /etc/group.edit
|
|
|
|
# An "editor" that appends a line, and one that sleeps so a race can be
|
|
# arranged. Both come back within the same second as the copy was made,
|
|
# which the GNU tool would misread as "unchanged".
|
|
cat > /usr/local/bin/ed-append <<'E'
|
|
#!/bin/sh
|
|
printf '%s\n' "$APPEND" >> "$1"
|
|
E
|
|
cat > /usr/local/bin/ed-slow <<'E'
|
|
#!/bin/sh
|
|
sleep 3
|
|
printf '%s\n' "$APPEND" >> "$1"
|
|
E
|
|
chmod +x /usr/local/bin/ed-append /usr/local/bin/ed-slow
|
|
|
|
assert_ok "vipw installs an edit" \
|
|
env APPEND='vp_alice:x:5601:5601::/home/vp_alice:/bin/sh' EDITOR=ed-append vipw -q
|
|
assert_file_contains "the line is in /etc/passwd" /etc/passwd '^vp_alice:x:5601:'
|
|
assert_ok "the mode of /etc/passwd is intact" \
|
|
bash -c "test \"\$(stat -c %a /etc/passwd)\" = 644"
|
|
assert_ok "no working copy is left behind" bash -c "! test -e /etc/passwd.edit"
|
|
|
|
assert_ok "vigr installs an edit" \
|
|
env APPEND='vp_team:x:5602:vp_alice' EDITOR=ed-append vigr -q
|
|
assert_file_contains "the group is in /etc/group" /etc/group '^vp_team:x:5602:vp_alice'
|
|
assert_ok "the mode of /etc/shadow survives vipw -s" \
|
|
bash -c "EDITOR=true vipw -s -q && test \"\$(stat -c %a /etc/shadow)\" = 640"
|
|
|
|
# The reason the tool exists: while the editor is open, another tool that
|
|
# wants the same file waits, and both changes land.
|
|
APPEND='vp_racer_placeholder:x:5603:5603:::' EDITOR=ed-slow vipw -q &
|
|
sleep 1
|
|
assert_ok "useradd waits for the lock rather than failing" useradd -M vp_racer
|
|
wait
|
|
assert_file_contains "the hand edit landed" /etc/passwd '^vp_racer_placeholder:'
|
|
assert_file_contains "and so did the concurrent useradd" /etc/passwd '^vp_racer:'
|
|
|
|
# A result the suite cannot parse is refused and the edit kept.
|
|
assert_fail "an unparseable result is refused" \
|
|
env APPEND='this has no colons' EDITOR=ed-append vipw -q
|
|
assert_file_not_contains "the live file is untouched" /etc/passwd 'no colons'
|
|
assert_ok "the edit is kept beside the file" \
|
|
bash -c "grep -q 'no colons' /etc/passwd.edit"
|
|
rm -f /etc/passwd.edit
|
|
|
|
assert_fail "an editor that fails installs nothing" \
|
|
env EDITOR=false vipw -q
|
|
|
|
userdel -r vp_racer 2>/dev/null || true
|
|
userdel vp_racer_placeholder 2>/dev/null || true
|
|
userdel -r vp_alice 2>/dev/null || true
|
|
groupdel vp_team 2>/dev/null || true
|
|
rm -f /usr/local/bin/ed-append /usr/local/bin/ed-slow
|
|
}
|
|
|
|
# ── newusers: batch account creation ───────────────────────────────
|
|
|
|
test_newusers() {
|
|
section "newusers — batch account creation"
|
|
|
|
for u in nu_alice nu_bob nu_svc; do userdel -r $u 2>/dev/null || true; done
|
|
groupdel nu_shared 2>/dev/null || true
|
|
assert_ok "groupadd nu_shared" groupadd nu_shared
|
|
|
|
assert_ok "a batch of three accounts applies" \
|
|
bash -c "printf 'nu_alice:pw1:::Alice:/home/nu_alice:/bin/bash\nnu_bob:pw2::nu_shared:Bob:/home/nu_bob:/bin/sh\nnu_svc:pw3:::Service::/usr/sbin/nologin\n' | newusers"
|
|
|
|
assert_file_contains "nu_alice is in passwd" /etc/passwd '^nu_alice:x:'
|
|
assert_file_contains "nu_alice has a hash in shadow" /etc/shadow '^nu_alice:\$'
|
|
assert_file_contains "nu_alice got a group of her own" /etc/group '^nu_alice:'
|
|
assert_ok "nu_alice has a home" test -d /home/nu_alice
|
|
assert_ok "the skeleton reached it" bash -c "ls -A /home/nu_alice | grep -q ."
|
|
assert_ok "the home belongs to her" \
|
|
bash -c "test \"\$(stat -c %U /home/nu_alice)\" = nu_alice"
|
|
assert_ok "and is private" \
|
|
bash -c "test \"\$(stat -c %a /home/nu_alice)\" = 700"
|
|
|
|
# A named group in the gid field is used rather than a new one invented.
|
|
assert_ok "nu_bob landed in the named group" \
|
|
bash -c "test \"\$(id -gn nu_bob)\" = nu_shared"
|
|
|
|
# An empty home field means no directory at all.
|
|
assert_ok "nu_svc has no home directory" bash -c "! test -e /home/nu_svc"
|
|
|
|
# The default scheme is the host's, from login.defs, not a hard-coded one.
|
|
assert_file_contains "the default scheme follows login.defs (yescrypt here)" \
|
|
/etc/shadow '^nu_alice:\$y\$'
|
|
|
|
# And the hash has to actually encode the password supplied, not merely
|
|
# look like a hash. Re-hash the plaintext with the salt taken from the
|
|
# stored field and compare -- a tool that wrote a well-formed hash of the
|
|
# wrong string passes every shape check above and fails this one. Done with
|
|
# -c SHA512 because openssl can recompute that one.
|
|
userdel -r nu_check 2>/dev/null || true
|
|
assert_ok "an account hashed with SHA512" \
|
|
bash -c "printf 'nu_check:checkpw:::::\n' | newusers -c SHA512"
|
|
assert_ok "the stored hash really encodes the supplied password" \
|
|
bash -c 'stored=$(grep "^nu_check:" /etc/shadow | cut -d: -f2); \
|
|
salt=$(printf "%s" "$stored" | cut -d"$" -f3); \
|
|
test "$stored" = "$(openssl passwd -6 -salt "$salt" checkpw)"'
|
|
assert_fail "and does not encode a different one" \
|
|
bash -c 'stored=$(grep "^nu_check:" /etc/shadow | cut -d: -f2); \
|
|
salt=$(printf "%s" "$stored" | cut -d"$" -f3); \
|
|
test "$stored" = "$(openssl passwd -6 -salt "$salt" wrongpw)"'
|
|
userdel -r nu_check 2>/dev/null || true
|
|
|
|
# All or nothing.
|
|
for u in nu_x nu_y; do userdel -r $u 2>/dev/null || true; done
|
|
assert_fail "a batch with a bad line fails" \
|
|
bash -c "printf 'nu_x:pw:::::\nbadline\n' | newusers"
|
|
assert_ok "and created nothing" bash -c "! grep -q '^nu_x:' /etc/passwd"
|
|
|
|
assert_fail "an empty password is refused" \
|
|
bash -c "printf 'nu_y::::::\n' | newusers"
|
|
assert_ok "leaving no account behind" bash -c "! grep -q '^nu_y:' /etc/passwd"
|
|
|
|
assert_ok "empty input succeeds having done nothing" \
|
|
bash -c "newusers < /dev/null"
|
|
|
|
for u in nu_alice nu_bob nu_svc; do userdel -r $u 2>/dev/null || true; done
|
|
groupdel nu_shared 2>/dev/null || true
|
|
}
|
|
|
|
# ── chgpasswd: group passwords in batch ────────────────────────────
|
|
|
|
test_chgpasswd() {
|
|
section "chgpasswd — group passwords in batch"
|
|
|
|
groupdel cg_one 2>/dev/null || true
|
|
groupdel cg_two 2>/dev/null || true
|
|
assert_ok "groupadd cg_one" groupadd cg_one
|
|
assert_ok "groupadd cg_two" groupadd cg_two
|
|
|
|
assert_ok "a batch of two group passwords applies" \
|
|
bash -c "printf 'cg_one:first\ncg_two:second\n' | chgpasswd"
|
|
assert_file_contains "cg_one got a hash in gshadow" \
|
|
/etc/gshadow '^cg_one:\$'
|
|
assert_file_contains "cg_two got a hash in gshadow" \
|
|
/etc/gshadow '^cg_two:\$'
|
|
assert_file_contains "/etc/group keeps the placeholder" \
|
|
/etc/group '^cg_one:x:'
|
|
|
|
# The password has to be the one a non-member is actually checked against,
|
|
# or the tool has written something decorative.
|
|
userdel -r cg_outsider 2>/dev/null || true
|
|
assert_ok "useradd -m cg_outsider" useradd -m cg_outsider
|
|
assert_contains "sg accepts the password chgpasswd set" "cg_one" \
|
|
bash -c "echo first | su -s /bin/bash cg_outsider -c \"sg cg_one -c 'id -gn'\""
|
|
assert_fail "and refuses a wrong one" \
|
|
bash -c "echo wrong | su -s /bin/bash cg_outsider -c \"sg cg_one -c 'id -gn'\""
|
|
|
|
# All or nothing: one unknown group and the whole batch is discarded.
|
|
# The line is compared through a file: a hash is full of dollar signs, and
|
|
# carrying one through a shell variable expands them away.
|
|
grep '^cg_two:' /etc/gshadow > /tmp/cg_two.before
|
|
assert_fail "a batch naming an unknown group fails" \
|
|
bash -c "printf 'cg_two:changed\nnosuchgroup:x\n' | chgpasswd"
|
|
assert_ok "and changed nothing" \
|
|
bash -c "grep '^cg_two:' /etc/gshadow | diff -q - /tmp/cg_two.before"
|
|
|
|
assert_ok "-e stores a field verbatim" \
|
|
bash -c "printf 'cg_one:\$6\$salt\$hash\n' | chgpasswd -e"
|
|
assert_file_contains "the verbatim field is in gshadow" \
|
|
/etc/gshadow '^cg_one:\$6\$salt\$hash:'
|
|
|
|
assert_ok "empty input succeeds having done nothing" \
|
|
bash -c "chgpasswd < /dev/null"
|
|
|
|
userdel -r cg_outsider 2>/dev/null || true
|
|
groupdel cg_one 2>/dev/null || true
|
|
groupdel cg_two 2>/dev/null || true
|
|
}
|
|
|
|
# ── sg: running one command in another group ───────────────────────
|
|
|
|
test_sg_group_switch() {
|
|
section "sg — running one command in another group"
|
|
|
|
# sg is setuid for the same reason newgrp is: a caller who is not a member
|
|
# of the target group has to be checked against the group password in
|
|
# /etc/gshadow, which is root-only. Nothing else in this suite runs sg as
|
|
# an unprivileged user, so dropping sg from the setuid list fails here and
|
|
# nowhere else.
|
|
userdel -r sg_member 2>/dev/null || true
|
|
userdel -r sg_outsider 2>/dev/null || true
|
|
groupdel sg_team 2>/dev/null || true
|
|
groupdel sg_locked 2>/dev/null || true
|
|
|
|
assert_ok "useradd -m sg_member" useradd -m sg_member
|
|
assert_ok "useradd -m sg_outsider" useradd -m sg_outsider
|
|
assert_ok "groupadd sg_team" groupadd sg_team
|
|
assert_ok "groupadd sg_locked" groupadd sg_locked
|
|
assert_ok "gpasswd -a sg_member sg_team" gpasswd -a sg_member sg_team
|
|
|
|
assert_contains "a member runs a command in the group" "sg_team" \
|
|
su -s /bin/bash sg_member -c "sg sg_team -c 'id -gn'"
|
|
assert_contains "-c is optional" "sg_team" \
|
|
su -s /bin/bash sg_member -c "sg sg_team 'id -gn'"
|
|
|
|
# sg changes the group, not the user: anything else would make it su.
|
|
assert_contains "the caller keeps their own identity" "sg_member" \
|
|
su -s /bin/bash sg_member -c "sg sg_team -c 'id -un'"
|
|
|
|
# The group the caller started in must still be reachable afterwards, or
|
|
# the switch would cost them access to their own files.
|
|
assert_contains "the original primary group survives the switch" "sg_member" \
|
|
su -s /bin/bash sg_member -c "sg sg_team -c 'id -Gn'"
|
|
|
|
# sg execs the command, so its status is the caller's status.
|
|
assert_ok "the command's exit status reaches the caller" \
|
|
bash -c "su -s /bin/bash sg_member -c \"sg sg_team -c 'exit 7'\"; test \$? -eq 7"
|
|
|
|
# The setuid path: a non-member authenticating against the group password.
|
|
printf 'teampw\nteampw\n' | gpasswd sg_team >/dev/null 2>&1
|
|
assert_contains "a non-member with the group password gets in" "sg_team" \
|
|
bash -c "echo teampw | su -s /bin/bash sg_outsider -c \"sg sg_team -c 'id -gn'\""
|
|
assert_fail "a wrong group password is refused" \
|
|
bash -c "echo wrong | su -s /bin/bash sg_outsider -c \"sg sg_team -c 'id -gn'\""
|
|
|
|
# A group with no password admits nobody who is not already a member.
|
|
assert_fail "a non-member is refused a group with no password" \
|
|
bash -c "echo anything | su -s /bin/bash sg_outsider -c \"sg sg_locked -c 'id -gn'\""
|
|
|
|
assert_fail "an unknown group is refused" \
|
|
su -s /bin/bash sg_member -c "sg no_such_group_at_all -c true"
|
|
|
|
groupdel sg_team 2>/dev/null || true
|
|
groupdel sg_locked 2>/dev/null || true
|
|
userdel -r sg_member 2>/dev/null || true
|
|
userdel -r sg_outsider 2>/dev/null || true
|
|
}
|
|
|
|
# ── nscd cache invalidation ────────────────────────────────────────
|
|
|
|
test_nscd() {
|
|
section "nscd cache invalidation"
|
|
|
|
# Start nscd (needs /var/run/nscd directory and /var/db/nscd)
|
|
mkdir -p /var/run/nscd /var/db/nscd 2>/dev/null || true
|
|
nscd -d >/dev/null 2>&1 &
|
|
sleep 2
|
|
|
|
if ! pgrep -x nscd >/dev/null 2>&1; then
|
|
echo -e " ${YELLOW}⊘${NC} nscd could not be started, skipping"
|
|
PASS=$((PASS + 1))
|
|
return
|
|
fi
|
|
|
|
assert_ok "nscd is running" pgrep -x nscd
|
|
|
|
# Create user and verify getent picks it up
|
|
userdel nscd_user 2>/dev/null || true
|
|
assert_ok "useradd -m nscd_user" useradd -m nscd_user
|
|
assert_contains "getent finds new user" "nscd_user" \
|
|
getent passwd nscd_user
|
|
|
|
# Delete user and verify getent no longer finds it
|
|
assert_ok "userdel -r nscd_user" userdel -r nscd_user
|
|
sleep 1
|
|
assert_fail "getent no longer finds deleted user" \
|
|
getent passwd nscd_user
|
|
|
|
# Stop nscd
|
|
killall nscd 2>/dev/null || true
|
|
}
|
|
|
|
# ── Landlock sandboxing ─────────────────────────────────────────────
|
|
|
|
test_landlock() {
|
|
section "Landlock sandboxing"
|
|
|
|
# Check if kernel supports Landlock
|
|
if [ -f /sys/kernel/security/landlock/abi_version ]; then
|
|
local abi_version
|
|
abi_version=$(cat /sys/kernel/security/landlock/abi_version)
|
|
echo -e " ${GREEN}✓${NC} Landlock ABI version: $abi_version"
|
|
PASS=$((PASS + 1))
|
|
|
|
# passwd should work under Landlock restriction
|
|
userdel -r landlock_user 2>/dev/null || true
|
|
assert_ok "useradd -m landlock_user" useradd -m landlock_user
|
|
|
|
assert_ok "passwd -S works under Landlock" \
|
|
passwd -S landlock_user
|
|
# The mutation path spawns nscd/logger and rewrites the file inside
|
|
# the sandbox; both have to be allowed by the rule set.
|
|
assert_ok "passwd -l works under Landlock" passwd -l landlock_user
|
|
assert_file_contains "lock applied under Landlock" \
|
|
/etc/shadow '^landlock_user:!'
|
|
assert_ok "passwd -u works under Landlock" passwd -u landlock_user
|
|
assert_ok "passwd -x 90 works under Landlock" passwd -x 90 landlock_user
|
|
assert_ok "unprivileged passwd -S works under Landlock" \
|
|
su -s /bin/bash testrunner -c "passwd -S testrunner"
|
|
|
|
userdel -r landlock_user 2>/dev/null || true
|
|
else
|
|
echo -e " ${YELLOW}⊘${NC} Landlock not available (kernel too old), skipping"
|
|
PASS=$((PASS + 1))
|
|
fi
|
|
}
|
|
|
|
# ── Ansible integration ────────────────────────────────────────────
|
|
|
|
test_ansible() {
|
|
section "Ansible integration"
|
|
|
|
if command -v ansible-playbook >/dev/null 2>&1; then
|
|
# Clean up from any previous failed run
|
|
userdel -r ansibleuser 2>/dev/null || true
|
|
groupdel ansiblegroup 2>/dev/null || true
|
|
|
|
assert_ok "ansible-playbook runs successfully" \
|
|
ansible-playbook -c local -i "localhost," /tests/e2e/ansible-test.yml
|
|
else
|
|
echo -e " ${YELLOW}⊘${NC} ansible-playbook not found, skipping"
|
|
PASS=$((PASS + 1))
|
|
fi
|
|
}
|
|
|
|
# ── Main ────────────────────────────────────────────────────────────
|
|
|
|
main() {
|
|
echo -e "${BLUE}shadow-rs end-to-end deployment tests${NC}"
|
|
echo "Running as: $(whoami) ($(id -u))"
|
|
echo "Binary: $BINDIR/shadow-rs"
|
|
echo ""
|
|
|
|
if [ "$(id -u)" -ne 0 ]; then
|
|
echo -e "${RED}ERROR: must run as root${NC}"
|
|
exit 1
|
|
fi
|
|
|
|
preflight
|
|
test_symlink_dispatch
|
|
test_multicall_dispatch
|
|
test_setuid
|
|
test_user_lifecycle
|
|
test_group_lifecycle
|
|
test_individual_tools
|
|
test_pam_auth
|
|
test_self_service
|
|
test_gpasswd_group_admin
|
|
test_sg_group_switch
|
|
test_chgpasswd
|
|
test_newusers
|
|
test_vipw
|
|
test_pwconv_family
|
|
test_login
|
|
test_idmap
|
|
test_subid_and_expiry
|
|
test_aging_and_input
|
|
test_audit_logging
|
|
test_root_option
|
|
test_nscd
|
|
test_landlock
|
|
test_ansible
|
|
|
|
echo ""
|
|
echo -e "${BLUE}── Results ──${NC}"
|
|
echo -e " ${GREEN}Passed: $PASS${NC}"
|
|
if [ "$FAIL" -gt 0 ]; then
|
|
echo -e " ${RED}Failed: $FAIL${NC}"
|
|
echo ""
|
|
echo -e "${RED}SOME TESTS FAILED${NC}"
|
|
exit 1
|
|
else
|
|
echo -e " ${RED}Failed: 0${NC}"
|
|
echo ""
|
|
echo -e "${GREEN}ALL TESTS PASSED${NC}"
|
|
exit 0
|
|
fi
|
|
}
|
|
|
|
main "$@"
|