mirror of
https://github.com/uutils/shadow.git
synced 2026-10-16 02:48:14 -07:00
* --root is a real chroot in every tool, not a path prefix in eight of them
Five tools called chroot(2) for --root. The other eight -- groupadd,
groupdel, groupmod, grpck, pwck, useradd, userdel, usermod -- folded the
argument into the same resolver --prefix uses, so it only prepended the
directory to the files the tool itself opened.
The difference shows whenever an absolute path is read out of a record
rather than being one of the tool's own files:
useradd -R /mnt/sysroot -m alice
created the home on the *host*, at /home/alice, because the path came
back from login.defs as /home/alice and nothing prefixed it. The skeleton
was copied from the host's /etc/skel. userdel -r -R had the mirror-image
problem and resolved the home to delete against /.
All thirteen now chroot, through one helper in shadow-core rather than
five copies of it, and refuse --root for anyone but root: a setuid-root
binary pointed at a tree of the caller's choosing would read and write
account files that caller controls.
pwck and grpck have no --prefix, matching GNU, so their tests move to a
spawned process: --root cannot be exercised in-process at all now, since
a chroot in the test binary takes every later test into the tree with it,
where /tmp does not exist. That is how this was caught. The other tools'
in-process tests use -P, which is what they meant.
Verified in the deployment container against a minimal target tree: the
account, its home and the skeleton all land inside it, groupadd/groupmod/
groupdel operate on the tree's group file, pwck and grpck read the tree,
userdel -r removes the home from the tree, and the host is untouched
throughout.
Closes #270.
* pwck: name the shadow file in the bad-entry test
The test passed only the passwd file, so the shadow path defaulted to the
host's /etc/shadow. Unprivileged, that cannot be read, and the test
reported 'cannot open' (exit 3) instead of the bad entry it is about
(exit 2). It passed in the containers, which run as root, and failed on
the host runner.
It was --root that hid this: as a path prefix it pointed the default at
the fixture. --root is a chroot now, so the fixture has to name both
files.
* tests: guard the pwck tests that chroot
They spawn 'pwck --root', and chroot(2) needs root: the tool refuses the
option for anyone else. The containers run as root so they passed there,
and the host runner failed them.
955 lines
38 KiB
Bash
Executable File
955 lines
38 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 chage groupadd groupdel groupmod grpck chfn chsh newgrp"
|
|
SETUID_TOOLS="passwd chfn chsh newgrp"
|
|
|
|
# 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="passwd chfn chsh newgrp chage"
|
|
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 → 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:"
|
|
|
|
# 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"
|
|
}
|
|
|
|
# ── 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_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 "$@"
|