#!/usr/bin/env bash # fleet-ownership-audit.sh — find files in root's territory owned by a normal # user account. # # Why it exists: elway's sudo upload was scp-as-user then `sudo mv`, which # keeps the uploader's ownership. So every file elway installed "as root" # (systemd units, /etc configs, root-run scripts) landed owned by infra-ops or # lkraven. Fixed in elway 2026-09-23; this finds what it left behind, and # anything else placed the same way by hand. # # Read-only. Three tiers per host: # A /etc /usr/local /root /var/spool/cron — root parses or executes all of # it, so every file there owned by a normal user is listed (finding). # X a file under /opt /srv /home /volume1 that a unit running as ROOT # names on an Exec*= line, and that is not root-owned (finding). # B /opt and /volume1/compose app trees — user-owned is often by design, # so only a per-directory count is printed (info, not a finding). # /opt/docker and /opt/containerd are skipped (compose/conf is user-owned by # design via deploy-stack.sh and read by containers). -xdev keeps it off NFS # and /etc/pve. A symlink in tier A is judged by its TARGET's owner (the # link's own owner cannot retarget it inside a root-owned dir, but a # user-writable target is still root running user code). Tier X reads unit # drop-ins too. "Normal user" = uid >= 1000 except nobody (65534); system # users below 1000 (postgres, _apt, ...) legitimately own things in /etc. # # scripts/fleet-ownership-audit.sh # whole fleet (SureFire excluded) # scripts/fleet-ownership-audit.sh fv-ml1 ... # named hosts only # # Exit: 0 clean, 4 findings, 5 INCOMPLETE — a host could not be audited, or # was only partly audited (no root, or a find that failed). A host that was # not fully read is never reported as clean. set -uo pipefail KEY="$HOME/.ssh/infra-ops_ed25519" # name → ssh target. A bare address means the infra-ops identity (NOPASSWD # sudo). user@address means infra-ops is NOT provisioned there (measured # 2026-09-23: key refused and no infra-ops account) and the host's registered # login is used instead; without sudo the audit runs unprivileged and says so # in its header line — 0700 dirs such as /root are then not covered. # SureFire tenant hosts (sf-*, sfsrv-ana) are deliberately absent: client # property under the hosting agreement. declare -A TARGET=( [ana-docker]=10.250.50.70 [ana-filebot]=10.250.50.53 [ana-nas]=10.250.50.50 [ana-wg]=root@10.250.50.252 [corviduo-dev]=10.250.50.152 [esh-docker-vm]=10.0.50.45 [esh-pve]=10.0.250.35 [esh-pve-nas]=10.0.50.55 [esh-vm-db]=10.0.50.60 [fv-ml1]=10.251.50.54 [irv-ml1]=irv-ml1.nh3.internal [nh3-dev]=10.100.10.50 [nh3-docker]=10.100.50.40 [nh3-extdev]=10.100.50.42 [nh3-nas]=syncuser@10.100.50.50 [nh3-pve]=10.100.250.60 [pbs-ana]=lkraven@10.250.50.90 [pbs-nh3]=lkraven@10.100.50.90 [pfi-ana-webhost]=10.250.50.52 [pfi-gx10]=10.100.50.60 [pfi-postgres]=lkraven@10.250.50.80 [pfi-pteradactyl]=10.250.50.55 [pfi-pve]=10.250.250.31 [pfi-tacticalrmm]=10.250.50.57 [vm-esh-nas]=lkraven@10.0.50.154 ) REMOTE=$(cat <<'EOF' # $1 = a per-run nonce. The completion line carries it so a filename that # happens to contain a newline plus "AUDIT-DONE" cannot fake completion. NONCE="$1" S=""; sudo -n true 2>/dev/null && S="sudo -n" partial="" if [ "$(id -u)" = 0 ]; then echo "PRIV=root" elif [ -n "$S" ]; then echo "PRIV=sudo" else echo "PRIV=none"; partial="unprivileged: 0700 dirs such as /root were not read"; fi ex() { for d in "$@"; do [ -d "$d" ] && printf '%s ' "$d"; done; } # Tier A — root parses or executes everything here: list every file. # A user's own crontab (/var/spool/cron/crontabs/, owned by ) is # how Debian's cron works, not a finding. A_ROOTS=$(ex /etc /usr/local /root /var/spool/cron) $S find $A_ROOTS -xdev -uid +999 ! -uid 65534 ! -type l \ -printf 'A %u:%g %m %TY-%Tm-%Td %y %p\n' 2>/dev/null \ | awk '{split($2,o,":"); n=split($6,p,"/"); if ($6 ~ "^/var/spool/cron/crontabs/" && p[n] == o[1]) next; print}' [ "${PIPESTATUS[0]}" -eq 0 ] || partial="${partial:+$partial; }tier A find exited non-zero" # A symlink's own owner does not matter (it cannot be retargeted inside a # root-owned dir), but what it POINTS AT does: /etc/cron.daily/x -> a # user-writable script is root running user code. Resolve and judge the target. # One privileged find with a batched -exec, not a sudo per link: infra-ops's # sudo keeps an I/O log, and /etc holds hundreds of symlinks. # Links into /proc (/etc/mtab -> ../proc/self/mounts) resolve to whoever is # looking, so they are skipped. $S find $A_ROOTS -xdev -type l ! -lname '*proc/*' -exec stat -L -c '%u %U:%G %a %n' {} + 2>/dev/null \ | awk '$1 >= 1000 && $1 != 65534 { $1 = ""; print "A" $0 " (symlink: its TARGET is user-owned)" }' # Tier X — a file under /opt /srv /home /volume1 named on an Exec*= line of a # unit that runs as ROOT, and not root-owned. Drop-ins are read with the unit # (User= and Exec*= can live only there), last User= wins as in systemd. # Quoted paths ("/opt/my app/run.sh") are taken whole. for u in /etc/systemd/system/*.service /lib/systemd/system/*.service /usr/lib/systemd/system/*.service; do [ -f "$u" ] || continue name=${u##*/} body=$(cat "$u" /lib/systemd/system/"$name".d/*.conf /usr/lib/systemd/system/"$name".d/*.conf \ /etc/systemd/system/"$name".d/*.conf 2>/dev/null) user=$(printf '%s\n' "$body" | grep -E '^[[:space:]]*User=' | tail -1 | cut -d= -f2 | tr -d '[:space:]') [ -n "$user" ] && [ "$user" != root ] && [ "$user" != 0 ] && continue printf '%s\n' "$body" | grep -E '^[[:space:]]*Exec[A-Za-z]*=' \ | grep -oE '"/(opt|srv|home|volume1)/[^"]+"|/(opt|srv|home|volume1)/[^ ;"'"'"']+' \ | tr -d '"' | sort -u | while IFS= read -r f; do [ -e "$f" ] || continue t=$($S stat -L -c '%u %U:%G %a %y %n' "$f" 2>/dev/null) || continue [ "${t%% *}" = 0 ] || echo "X ${t#* } [run as root by $name]" done done # Tier B — app trees under /opt and Synology compose: user-owned is often by # design, so SUMMARISE per top-level dir rather than list. $S find $(ex /opt /volume1/compose) -xdev \( -path /opt/docker -o -path /opt/containerd \) -prune \ -o -uid +999 ! -uid 65534 ! -type l -printf '%u %p\n' 2>/dev/null \ | awk '{n=split($2,a,"/"); k=$1" /"a[2]"/"a[3]; c[k]++} END{for(k in c) print "B", c[k], k}' [ -n "$partial" ] && echo "PARTIAL=$partial" echo "AUDIT-DONE-$NONCE" EOF ) if [ $# -gt 0 ]; then hosts=("$@"); else mapfile -t hosts < <(printf '%s\n' "${!TARGET[@]}" | sort); fi findings=0; unreachable=(); partial_hosts=() for h in "${hosts[@]}"; do t="${TARGET[$h]:-}" if [ -z "$t" ]; then echo "== $h: UNKNOWN HOST (not audited)"; unreachable+=("$h"); continue; fi if [[ "$t" == *@* ]]; then ssh_to=(ssh -o BatchMode=yes -o ConnectTimeout=8 "$t") else ssh_to=(ssh -o BatchMode=yes -o ConnectTimeout=8 -i "$KEY" "infra-ops@$t"); fi nonce=$(od -An -N8 -tx8 /dev/urandom | tr -d ' ') out=$(timeout 90 "${ssh_to[@]}" "bash -s -- $nonce" <<<"$REMOTE" 2>/dev/null) if ! grep -qx "AUDIT-DONE-$nonce" <<<"$out"; then echo "== $h: UNREACHABLE or run died before completing (not audited)"; unreachable+=("$h"); continue fi priv=$(sed -n 's/^PRIV=//p' <<<"$out") part=$(sed -n 's/^PARTIAL=//p' <<<"$out") rows=$(grep -E '^(A|X) ' <<<"$out" | awk '!seen[$0]++') summary=$(grep -E '^B ' <<<"$out" | sort -k2,2nr) n=$(grep -c . <<<"$rows") if [ -n "$part" ]; then echo "== $h: $n finding(s) — PARTIAL, not a clean result ($part) [priv: $priv]" partial_hosts+=("$h") else echo "== $h: $n finding(s) [priv: $priv]" fi [ "$n" -gt 0 ] && { printf '%s\n' "$rows" | sed 's/^/ /'; findings=$((findings + n)); } [ -n "$summary" ] && printf '%s\n' "$summary" | sed -E 's/^B ([0-9]+) (\S+) (.*)/ info: \1 file(s) owned by \2 under \3/' done echo if [ ${#unreachable[@]} -gt 0 ] || [ ${#partial_hosts[@]} -gt 0 ]; then [ ${#unreachable[@]} -gt 0 ] && echo "INCOMPLETE: ${#unreachable[@]} host(s) not audited: ${unreachable[*]}" [ ${#partial_hosts[@]} -gt 0 ] && echo "INCOMPLETE: ${#partial_hosts[@]} host(s) only partly audited: ${partial_hosts[*]}" echo " $findings finding(s) on what was reached." exit 5 fi echo "$findings finding(s) across ${#hosts[@]} host(s)." [ "$findings" -eq 0 ] && exit 0 || exit 4