#!/usr/bin/env bash # Verify that a deprovisioned account has genuinely released its uid. # # Written as the verification half of `docs/deprovision-os-account.md`, deliberately OUTSIDE the # implementation: if the function under test calls its own checker, the check is a restatement rather than # an audit. This runs against the machine and knows nothing about the code that was supposed to clean it. # # The subuid range must be captured BEFORE the account is deleted, because `userdel` removes the # /etc/subuid entry along with the account — after which there is no way to ask what range it held, and a # check that silently skips that half is the failure mode this whole file exists to prevent. # # ./assert-uid-free.sh --capture green # before: prints "green 1001 165536 65536" # ./assert-uid-free.sh --check green 1001 165536 65536 # after: exits non-zero unless clean # set -uo pipefail DATA_PATH="${DATA_PATH:-/home/pastilhas/officerdev/data}" SEARCH_ROOTS=("$DATA_PATH" /home) usage() { echo "usage: $0 --capture | --check " >&2; exit 2; } if [[ "${1:-}" == "--capture" ]]; then user="${2:?user required}" uid="$(id -u "$user" 2>/dev/null)" || { echo "no such account: $user" >&2; exit 1; } range="$(awk -F: -v u="$user" '$1==u {print $2" "$3; exit}' /etc/subuid)" [[ -n "$range" ]] || { echo "no /etc/subuid entry for $user — capture it another way or it is unverifiable" >&2; exit 1; } echo "$user $uid $range" exit 0 fi [[ "${1:-}" == "--check" ]] || usage user="${2:?}"; uid="${3:?}"; sub_start="${4:?}"; sub_count="${5:?}" sub_end=$(( sub_start + sub_count - 1 )) fails=0 ok() { printf ' ok %s\n' "$1"; } bad() { printf ' FAIL %s\n' "$1"; fails=$((fails+1)); } echo "checking $user (uid $uid, subuids $sub_start-$sub_end)" getent passwd "$user" >/dev/null 2>&1 && bad "passwd entry still exists" || ok "no passwd entry" getent passwd "$uid" >/dev/null 2>&1 && bad "uid $uid reassigned or still present" || ok "uid $uid unused" grep -q "^$user:" /etc/subuid 2>/dev/null && bad "/etc/subuid entry remains" || ok "no /etc/subuid entry" grep -q "^$user:" /etc/subgid 2>/dev/null && bad "/etc/subgid entry remains" || ok "no /etc/subgid entry" [[ -e "/var/lib/systemd/linger/$user" ]] && bad "linger marker remains" || ok "no linger marker" [[ -d "/run/user/$uid" ]] && bad "/run/user/$uid remains" || ok "no runtime directory" procs="$(pgrep -u "$uid" 2>/dev/null | wc -l)" [[ "$procs" -eq 0 ]] && ok "no processes" || bad "$procs process(es) still owned by uid $uid" # The uid half. owned="$(find "${SEARCH_ROOTS[@]}" -uid "$uid" -print -quit 2>/dev/null)" [[ -z "$owned" ]] && ok "no files owned by uid $uid" || bad "files owned by uid $uid (e.g. $owned)" # The subuid half — the one a uid-only check passes straight through. Container processes running as a # non-root user inside their namespace write files owned by a MAPPED id, not by the member's uid, and # `userdel` frees the whole range for reallocation. mapped="$(find "${SEARCH_ROOTS[@]}" -uid +"$((sub_start-1))" ! -uid +"$sub_end" -print -quit 2>/dev/null)" [[ -z "$mapped" ]] && ok "no files in the freed subuid range" || bad "files owned by the freed subuid range (e.g. $mapped)" # ACL entries, which ownership checks cannot see. `confineUserTree` grants the member a NAMED entry on their # whole tree — `u::rwx` plus a `default:` copy — and `chown` does not remove them: they are xattrs, not # ownership, and they store the uid NUMERICALLY. So a tree reassigned to the service user can still carry # `user:1001:rwx` on every file, and the next account allocated 1001 inherits read/write on all of it. # # `-n` forces numeric output; after `userdel` the uid has no name to resolve to, and relying on the name # would make this check depend on the very passwd entry that is supposed to be gone. # # Scoped to DATA_PATH: member trees live there, and a recursive getfacl over /home would walk the owner's # entire account for no gain. acl_hit="$(getfacl -R -n -p "$DATA_PATH" 2>/dev/null | grep -m1 -E "^(default:)?user:$uid:")" [[ -z "$acl_hit" ]] && ok "no ACL entries naming uid $uid" || bad "ACL entries still grant uid $uid ($acl_hit)" echo if [[ "$fails" -eq 0 ]]; then echo "CLEAN — uid $uid and its subuid range are safe to reissue" exit 0 fi echo "NOT CLEAN — $fails check(s) failed; do not reissue this uid" exit 1