elway's sudo upload did scp-as-user then `sudo mv`, and mv keeps the owner,
so every file it installed "as root" (systemd units, /etc configs, root-run
scripts) ended up owned by the SSH user. A sudoers drop-in installed that
way would be rejected by sudo outright.
elway:
- Sudo uploads now chown to root:root by default. Playbooks can override
with `upload.owner:` and ad-hoc runs with `--owner`. An owner is refused
on a non-sudo upload, and `--owner` outside an ad-hoc `--upload` is an
error rather than silently ignored.
- Ownership and mode are applied to the STAGED file, then a single mv
publishes it, so a failed chown can no longer leave the live path owned
by the SSH user. chown runs before chmod so setuid bits survive. A trap
removes the staged file on every exit path.
- A directory dest is refused before anything moves. mv would otherwise
drop the file inside the directory under a staging name.
- `mode` was spliced unquoted into the remote root shell line. It is now
validated as octal (^[0-7]{3,4}$) and shell-quoted, on both the sudo
and non-sudo paths.
- `mode` and `owner` must be quoted YAML strings. YAML 1.1 turns a bare
0644 into 420, a bare 1000:0 into 60000 and a bare yes into True, and
str() of each is a different value that looks valid. All 85 playbooks
already quote them.
- New preflight(): every step is resolved before any remote action. An
undefined var or a templated owner that resolves badly is now refused
up front, not after earlier steps have already changed the host.
scripts/fleet-ownership-audit.sh (new, read-only) finds files in root's
territory owned by a normal user. Tier A lists /etc, /usr/local, /root and
cron, judging symlinks by their target. Tier X covers files that root-run
units exec from /opt, /srv, /home or /volume1, reading drop-ins and quoted
paths. Tier B only summarises /opt app trees. A host that is unprivileged,
whose find fails, or whose run dies is reported INCOMPLETE (exit 5), never
clean. Completion is marked with a per-run nonce.
Verification: 24 unit tests in scripts/test_elway.py. Live checks on
esh-docker-vm: default lands root:root; the override lands infra-ops:adm at
4755 with setuid intact; a bad group fails with dest untouched and no
staging left; a directory dest is refused; a bad mode is refused before
any copy. Audit positive controls on nh3-dev (a drop-in-only root Exec, a
quoted path containing a space, a symlink to a user-owned target) were all
flagged; esh-docker-vm negative control was 0. Probes removed. Cross-model
bug-hunt (heid, Gróa arm + seat) findings folded.
156 lines
8.2 KiB
Bash
Executable File
156 lines
8.2 KiB
Bash
Executable File
#!/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/<user>, owned by <user>) 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
|