diff --git a/docs/deprovision-os-account.md b/docs/deprovision-os-account.md index b5e74e8e..b9aa0896 100644 --- a/docs/deprovision-os-account.md +++ b/docs/deprovision-os-account.md @@ -146,8 +146,28 @@ All of these must hold for the freed uid *and* its freed subuid range: - `/run/user/` absent - no processes owned by the uid -Worth extracting as `assertUidFree(uid, subuidRange)` and reusing it as the post-condition of the function and -as a test. +`scripts/assert-uid-free.sh` implements exactly this, deliberately **outside** the function: a checker the +implementation calls is a restatement of its own beliefs, not an audit. Two modes, and the split matters — + +``` +./scripts/assert-uid-free.sh --capture green # BEFORE: prints "green 1001 165536 65536" +sudo ./scripts/assert-uid-free.sh --check green 1001 165536 65536 # AFTER: exit 1 unless clean +``` + +The range has to be captured **before** deletion, because `userdel` removes the `/etc/subuid` entry with the +account. After that there is no way to ask what range it held — and a check that silently skips that half is +the exact failure this section exists to prevent. + +**The subuid check passes vacuously on most accounts, and that is a trap.** Container files are owned by a +mapped id only when a process inside the container runs as a NON-root user; an image whose files are root-owned +maps to the member's own uid and leaves nothing in the range. Measured on green after a night of real use — +`claude` installed, images pulled, transcripts written — the range check found **zero** files and passed +without testing anything. + +To build a specimen that actually exercises it, run a container whose process writes as a non-root user. The +`postgres:18-alpine` case from the same night is the natural one: its entrypoint drops to uid 70, and the data +directory came out owned by `subuid_start + 70` on the host. Verify the range check *fails* on that tree before +trusting it to pass on a cleaned one. **Trap for the verifier:** do not use `sudo -u …` to check anything after step 2. Creating a session starts a user manager and recreates `/run/user/`, so the check would undo the step it is verifying. diff --git a/scripts/assert-uid-free.sh b/scripts/assert-uid-free.sh new file mode 100755 index 00000000..a01c9466 --- /dev/null +++ b/scripts/assert-uid-free.sh @@ -0,0 +1,69 @@ +#!/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)" + +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