From 466f7aa4e656af15e0dd6b9dc2cc4645be7f0114 Mon Sep 17 00:00:00 2001 From: Vuong Hoang Date: Wed, 23 Sep 2026 09:59:15 -0700 Subject: [PATCH] fix(elway): sudo uploads land root:root, validated and staged; add fleet ownership audit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../esh-docker-vm-restic-drop-kuma-block.yaml | 5 +- ...docker-vm-restic-drop-paperless-block.yaml | 5 +- scripts/elway | 125 +++++++- scripts/fleet-ownership-audit.sh | 155 ++++++++++ scripts/test_elway.py | 279 ++++++++++++++++++ 5 files changed, 555 insertions(+), 14 deletions(-) create mode 100755 scripts/fleet-ownership-audit.sh create mode 100644 scripts/test_elway.py diff --git a/playbooks/esh-docker-vm-restic-drop-kuma-block.yaml b/playbooks/esh-docker-vm-restic-drop-kuma-block.yaml index 2122231..54be458 100644 --- a/playbooks/esh-docker-vm-restic-drop-kuma-block.yaml +++ b/playbooks/esh-docker-vm-restic-drop-kuma-block.yaml @@ -24,8 +24,9 @@ steps: dest: /etc/restic/pre-backup.sh mode: '0700' - # elway's sudo upload is scp-as-user then `sudo mv`, so the file lands owned - # by the SSH user. This hook is executed by root: it must be root-owned. + # Belt and braces: elway's sudo upload defaults to root:root since + # 2026-09-23 (before that it kept the SSH user's ownership). This hook is + # executed by root, so the verify below checks it either way. - name: Make the hook root-owned sudo: true shell: chown root:root /etc/restic/pre-backup.sh diff --git a/playbooks/esh-docker-vm-restic-drop-paperless-block.yaml b/playbooks/esh-docker-vm-restic-drop-paperless-block.yaml index 5748b5e..a18c2d1 100644 --- a/playbooks/esh-docker-vm-restic-drop-paperless-block.yaml +++ b/playbooks/esh-docker-vm-restic-drop-paperless-block.yaml @@ -31,8 +31,9 @@ steps: dest: /etc/restic/pre-backup.sh mode: '0700' - # elway's sudo upload is scp-as-user then `sudo mv`, so the file lands owned - # by the SSH user. This hook is executed by root: it must be root-owned. + # Belt and braces: elway's sudo upload defaults to root:root since + # 2026-09-23 (before that it kept the SSH user's ownership). This hook is + # executed by root, so the verify below checks it either way. - name: Make the hook root-owned sudo: true shell: chown root:root /etc/restic/pre-backup.sh /var/lib/restic/repair-20260923/pre-backup.sh.pre-paperless diff --git a/scripts/elway b/scripts/elway index 0ac8a2d..e3a5d6b 100755 --- a/scripts/elway +++ b/scripts/elway @@ -35,7 +35,12 @@ Playbook schema (YAML): src: dest: mode: "0644" # optional + owner: "user:group" # optional, sudo only; default root:root sudo: false # default false + # A sudo upload lands root:root unless `owner:` says otherwise. (Before + # 2026-09-23 it kept the SSH user's ownership, because the staged file is + # scp'd as that user and then `sudo mv`'d into place.) Without sudo the + # file is the SSH user's by construction, and `owner:` is refused. stop_on_fail: true # optional per-step override of global flag # Idempotency (tier 1): if ANY of these conditions says 'skip', # the step is marked `skipped`, not run, not counted as failure. @@ -200,6 +205,53 @@ class UploadSpec: src: str dest: str mode: Optional[str] = None # octal string like "0644" + owner: Optional[str] = None # "user[:group]"; sudo only; None = root:root + + +# user or user:group — names or numeric ids. Deliberately narrow: the value is +# spliced into a remote shell line, and anything outside this set is a typo or +# an injection, not an owner. +OWNER_RE = re.compile(r"^[A-Za-z0-9_][A-Za-z0-9_.-]*(:[A-Za-z0-9_][A-Za-z0-9_.-]*)?$") +DEFAULT_SUDO_OWNER = "root:root" + + +# Octal only. Like owner, mode is spliced into a remote (often root) shell +# line, so anything else is refused rather than quoted-and-hoped. +MODE_RE = re.compile(r"^[0-7]{3,4}$") + + +def check_mode(mode: Optional[str], where: str) -> None: + if mode is not None and not MODE_RE.match(mode): + raise SystemExit(f"elway: {where} mode {mode!r} is not an octal mode like \"0644\"") + + +def _yaml_str_field(u: dict, key: str, where: str) -> Optional[str]: + """A field that must be a quoted YAML string. YAML 1.1 turns a bare 0644 + into the int 420 and a bare 1000:0 into 60000 (sexagesimal), and yes/no + into booleans; str() of any of those is a DIFFERENT, valid-looking value. + Refuse instead of guessing.""" + v = u.get(key) + if v is None: + return None + if not isinstance(v, str): + raise SystemExit( + f"elway: {where} {key} must be a quoted string (got {type(v).__name__} " + f"{v!r} — YAML reinterpreted it; write {key}: \"...\")" + ) + return v + + +def check_owner(owner: Optional[str], sudo: bool, where: str) -> None: + """Refuse an owner that is malformed, or set on a non-sudo upload.""" + if owner is None: + return + if not sudo: + raise SystemExit( + f"elway: {where} owner: needs sudo — without it the file is the " + f"SSH user's and cannot be chowned to anyone else" + ) + if not OWNER_RE.match(owner): + raise SystemExit(f"elway: {where} owner {owner!r} is not user or user:group") @dataclasses.dataclass @@ -287,7 +339,10 @@ def substitute_step(step: Step, vars: dict, where: str) -> Step: src=substitute(step.upload.src, vars, f"{where} upload.src"), dest=substitute(step.upload.dest, vars, f"{where} upload.dest"), mode=step.upload.mode, + owner=(substitute(step.upload.owner, vars, f"{where} upload.owner") + if step.upload.owner is not None else None), ) + check_owner(new.upload.owner, step.sudo, f"{where} upload") for field in ("when", "creates", "removes", "changed_when"): val = getattr(step, field) if val is not None: @@ -324,12 +379,19 @@ def _load_step(raw: dict, where: str) -> Step: u = raw["upload"] if not isinstance(u, dict) or "src" not in u or "dest" not in u: raise SystemExit(f"elway: {where} upload must have src and dest") - mode = u.get("mode") + mode = _yaml_str_field(u, "mode", f"{where} upload") + owner = _yaml_str_field(u, "owner", f"{where} upload") + check_mode(mode, f"{where} upload") upload = UploadSpec( src=_as_str(u["src"], f"{where} upload.src"), dest=_as_str(u["dest"], f"{where} upload.dest"), - mode=str(mode) if mode is not None else None, + mode=mode, + owner=owner, ) + if upload.owner is not None and not bool(raw.get("sudo", False)): + check_owner(upload.owner, False, f"{where} upload") + if upload.owner is not None and not VAR_RE.search(upload.owner): + check_owner(upload.owner, True, f"{where} upload") def opt_str(field: str) -> Optional[str]: v = raw.get(field) return None if v is None else _as_str(v, f"{where} {field}") @@ -521,7 +583,7 @@ def run_upload_step(ctx: SSHContext, step: Step, prefix: str) -> int: return 2 parent = os.path.dirname(up.dest) or "/" - mode_clause = f" && chmod {up.mode} {shlex.quote(up.dest)}" if up.mode else "" + check_mode(up.mode, "upload") if not step.sudo: # Non-sudo path: mkdir parent, scp direct, chmod. @@ -540,11 +602,15 @@ def run_upload_step(ctx: SSHContext, step: Step, prefix: str) -> int: if ctx.verbose: print(DIM(f"{prefix}$ ssh {ctx.host} chmod {up.mode} {up.dest}")) rc = _stream_process( - ctx.ssh_cmd(f"chmod {up.mode} {shlex.quote(up.dest)}"), None, prefix + ctx.ssh_cmd(f"chmod {shlex.quote(up.mode)} {shlex.quote(up.dest)}"), None, prefix ) return rc - # Sudo path: probe lazily, then scp to /tmp as user, then sudo mv + chmod server-side. + # Sudo path: probe lazily, then scp to /tmp as user, then sudo mv + chown + + # chmod server-side. Validate the owner BEFORE anything is copied, so a bad + # value can never orphan a staged file in the remote /tmp. + owner = up.owner or DEFAULT_SUDO_OWNER + check_owner(owner, True, "upload") ensure_sudo(ctx) staging = f"/tmp/elway.{os.getpid()}.{int(time.time() * 1000)}.{src.name}" if ctx.verbose: @@ -552,10 +618,26 @@ def run_upload_step(ctx: SSHContext, step: Step, prefix: str) -> int: rc = _stream_process(ctx.scp_cmd(str(src), staging), None, prefix) if rc != 0: return rc + # Prepare the STAGED file completely, then publish it with one mv. A chown + # that failed after mv would leave the live path owned by the SSH user — + # the exact state this exists to prevent — so ownership and mode are set + # before the file is visible at dest. mv is a same-fs rename (or, as root, + # an ownership-preserving copy), so both survive it. + # * chown BEFORE chmod: chown clears setuid/setgid, so a requested 4755 + # must be applied after it or it silently becomes 0755. + # * A directory dest is refused: `mv` would move INTO it (dest=/home/x + # would drop the file inside a home dir under a staging name). + # * The trap removes the staged file on every exit path; after a + # successful mv there is nothing left for it to remove. + qstage, qdest = shlex.quote(staging), shlex.quote(up.dest) + mode_clause = f" && chmod {shlex.quote(up.mode)} {qstage}" if up.mode else "" script = ( - f"mkdir -p {shlex.quote(parent)} " - f"&& mv -f {shlex.quote(staging)} {shlex.quote(up.dest)}" + f"trap {shlex.quote('rm -f -- ' + qstage)} EXIT; " + f"{{ [ ! -d {qdest} ] || {{ echo 'elway: upload dest is a directory: '{qdest} >&2; exit 2; }}; }} " + f"&& chown {shlex.quote(owner)} {qstage}" f"{mode_clause}" + f" && mkdir -p {shlex.quote(parent)} " + f"&& mv -f {qstage} {qdest}" ) remote = "sudo -S -p '' bash -c " + shlex.quote(script) if ctx.verbose: @@ -680,7 +762,8 @@ def run_phase( else: u = resolved.upload mode = f" mode={u.mode}" if u.mode else "" - print(f" {DIM('dry-run:')} upload: {u.src} → {u.dest}{mode}") + owner = (f" owner={u.owner or DEFAULT_SUDO_OWNER}" if resolved.sudo else "") + print(f" {DIM('dry-run:')} upload: {u.src} → {u.dest}{mode}{owner}") if resolved.when or resolved.creates or resolved.removes: conds = [] if resolved.when: conds.append(f"when={resolved.when!r}") @@ -835,6 +918,11 @@ def parse_args(argv: list[str]) -> argparse.Namespace: ) mode.add_argument("--playbook", "-p", help="path to a YAML playbook") p.add_argument("--sudo", action="store_true", help="run ad-hoc --shell/--upload with sudo") + p.add_argument( + "--owner", + default=None, + help="ad-hoc --upload --sudo only: USER[:GROUP] for the file (default root:root)", + ) p.add_argument( "--var", action="append", @@ -859,7 +947,11 @@ def parse_args(argv: list[str]) -> argparse.Namespace: action="store_true", help="Don't write run logs to disk; terminal-only.", ) - return p.parse_args(argv) + args = p.parse_args(argv) + if args.owner is not None and not args.upload: + # Ignoring it would let the caller believe an ownership was applied. + p.error("--owner applies only to an ad-hoc --upload (playbooks use upload.owner)") + return args def build_adhoc_playbook(args: argparse.Namespace) -> Playbook: @@ -877,14 +969,26 @@ def build_adhoc_playbook(args: argparse.Namespace) -> Playbook: raise SystemExit( "elway: --upload expected LOCAL:REMOTE or LOCAL:REMOTE:MODE" ) + check_owner(args.owner, args.sudo, "--upload") + check_mode(mode, "--upload") step = Step( name="ad-hoc upload", - upload=UploadSpec(src=src, dest=dest, mode=mode), + upload=UploadSpec(src=src, dest=dest, mode=mode, owner=args.owner), sudo=args.sudo, ) return Playbook(vars={}, steps=[step], verify=[]) +def preflight(pb: Playbook, merged_vars: dict) -> None: + """Resolve every step before anything runs. substitute_step raises on an + undefined variable or a templated owner that resolves to garbage; doing it + per-step inside run_phase meant earlier steps had already changed the host + by the time a later step's config error surfaced.""" + for phase, steps in (("steps", pb.steps), ("verify", pb.verify)): + for i, step in enumerate(steps, 1): + substitute_step(step, merged_vars, f"{phase} {i} ({step.name!r})") + + def main(argv: Optional[list[str]] = None) -> int: args = parse_args(argv if argv is not None else sys.argv[1:]) @@ -896,6 +1000,7 @@ def main(argv: Optional[list[str]] = None) -> int: # Merge vars: playbook defaults, overridden by CLI. merged_vars = dict(pb.vars) merged_vars.update(parse_var_args(args.var)) + preflight(pb, merged_vars) # config errors surface before any remote action # Set up run logging — tee stdout to log file(s) so subsequent reads # don't have to scroll the terminal. `--no-log` disables. diff --git a/scripts/fleet-ownership-audit.sh b/scripts/fleet-ownership-audit.sh new file mode 100755 index 0000000..b6a1a9f --- /dev/null +++ b/scripts/fleet-ownership-audit.sh @@ -0,0 +1,155 @@ +#!/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 diff --git a/scripts/test_elway.py b/scripts/test_elway.py new file mode 100644 index 0000000..70cae7b --- /dev/null +++ b/scripts/test_elway.py @@ -0,0 +1,279 @@ +"""Tests for elway's upload ownership (2026-09-23). + +elway's sudo upload used to be scp-as-user then `sudo mv`, and mv keeps the +source's owner — so every file elway installed as root landed owned by the SSH +user. These pin the fix: sudo uploads chown to root:root by default, an +explicit `owner:` overrides it, and bad input is refused before anything runs. + + python3 -m unittest scripts/test_elway.py +""" +import importlib.machinery +import importlib.util +import pathlib +import shlex +import sys +import tempfile +import textwrap +import unittest + +_PATH = pathlib.Path(__file__).with_name("elway") +_loader = importlib.machinery.SourceFileLoader("elway", str(_PATH)) +_spec = importlib.util.spec_from_loader("elway", _loader) +elway = importlib.util.module_from_spec(_spec) +sys.modules["elway"] = elway # dataclasses resolve annotations via sys.modules +_loader.exec_module(elway) + + +class FakeCtx: + host = "infra-ops@198.51.100.7" + verbose = False + sudo_password = None + + def ssh_cmd(self, remote_cmd): + return ["ssh", self.host, remote_cmd] + + def scp_cmd(self, src, dest): + return ["scp", src, f"{self.host}:{dest}"] + + +class UploadOwnershipTests(unittest.TestCase): + def setUp(self): + self.calls = [] + self._orig_stream = elway._stream_process + self._orig_sudo = elway.ensure_sudo + elway._stream_process = lambda cmd, stdin, prefix: self.calls.append(cmd) or 0 + elway.ensure_sudo = lambda ctx: None + self.addCleanup(self._restore) + tmp = tempfile.NamedTemporaryFile("w", suffix=".sh", delete=False) + tmp.write("#!/bin/sh\n") + tmp.close() + self.src = tmp.name + self.addCleanup(pathlib.Path(self.src).unlink) + + def _restore(self): + elway._stream_process = self._orig_stream + elway.ensure_sudo = self._orig_sudo + + def remote_scripts(self): + """The shell text each ssh call runs remotely (scp calls excluded): + for a sudo call, the script handed to `bash -c`, unwrapped.""" + out = [] + for c in self.calls: + if c[0] != "ssh": + continue + words = shlex.split(c[-1]) + out.append(words[-1] if words[:2] == ["sudo", "-S"] else c[-1]) + return out + + def run_upload(self, sudo, mode=None, owner=None): + up = elway.UploadSpec(src=self.src, dest="/etc/restic/pre-backup.sh", + mode=mode, owner=owner) + step = elway.Step(name="t", upload=up, sudo=sudo) + return elway.run_upload_step(FakeCtx(), step, "") + + def test_sudo_upload_defaults_to_root_ownership(self): + self.assertEqual(self.run_upload(sudo=True), 0) + (script,) = self.remote_scripts() + self.assertIn("chown root:root /tmp/elway.", script) + + def test_sudo_upload_explicit_owner_overrides_default(self): + self.run_upload(sudo=True, owner="booth:booth") + (script,) = self.remote_scripts() + self.assertIn("chown booth:booth /tmp/elway.", script) + self.assertNotIn("root:root", script) + + def test_owner_and_mode_are_set_on_the_staged_file_before_publishing(self): + # A chown that fails AFTER mv would leave the live path owned by the + # SSH user — the state this exists to prevent. So chown and chmod act + # on the staged file, and mv publishes only a fully-prepared file. + # chown clears setuid/setgid, so chmod still comes after chown. + self.run_upload(sudo=True, mode="0700") + (script,) = self.remote_scripts() + staging = self.calls[0][-1].split(":", 1)[1] # scp dest = staging path + chown, chmod, mv = (script.index(k) for k in ( + f"chown root:root {staging}", f"chmod 0700 {staging}", "mv -f")) + self.assertLess(chown, chmod) + self.assertLess(chmod, mv) + self.assertNotIn("chown root:root /etc/restic/pre-backup.sh", script) + + def test_staged_file_is_removed_on_every_exit_path(self): + self.run_upload(sudo=True) + (script,) = self.remote_scripts() + staging = self.calls[0][-1].split(":", 1)[1] + self.assertTrue(script.startswith(f"trap 'rm -f -- {staging}' EXIT"), script) + + def test_sudo_upload_refuses_a_directory_dest(self): + # `mv -f staged /some/dir` moves INTO the dir, and the chown/chmod that + # follow would then hit the directory itself — e.g. dest=/home/lkraven + # would chown the user's home to root. The remote line must refuse + # before mv when dest is an existing directory. + self.run_upload(sudo=True) + (script,) = self.remote_scripts() + guard = script.index("[ ! -d /etc/restic/pre-backup.sh ]") + self.assertLess(guard, script.index("mv -f")) + + def test_malformed_owner_is_refused_before_anything_is_copied(self): + # Defence in depth: even if a bad owner slipped past load-time checks, + # nothing may be scp'd to the host first (it would orphan the staged + # file in /tmp). + with self.assertRaises(SystemExit): + self.run_upload(sudo=True, owner="root; id") + self.assertEqual(self.calls, []) + + def test_non_sudo_upload_does_not_chown(self): + # Without sudo the file is the SSH user's by construction, and a chown + # to anyone else would fail anyway. + self.run_upload(sudo=False, mode="0644") + self.assertFalse(any("chown" in s for s in self.remote_scripts())) + + +class PlaybookOwnerParsingTests(unittest.TestCase): + def load(self, body): + return elway._load_step(__import__("yaml").safe_load(textwrap.dedent(body)), "step 1") + + def test_owner_is_parsed_from_playbook(self): + step = self.load(""" + name: x + sudo: true + upload: {src: a, dest: /etc/b, owner: "booth:booth"} + """) + self.assertEqual(step.upload.owner, "booth:booth") + + def test_owner_without_sudo_is_refused(self): + with self.assertRaises(SystemExit): + self.load(""" + name: x + upload: {src: a, dest: /tmp/b, owner: "booth:booth"} + """) + + def test_malformed_owner_is_refused(self): + for bad in ("root; rm -rf /", "a:b:c", "", " root", "$(id)"): + with self.subTest(owner=bad), self.assertRaises(SystemExit): + self.load(f""" + name: x + sudo: true + upload: {{src: a, dest: /etc/b, owner: {bad!r}}} + """) + + def test_user_only_and_numeric_owners_are_accepted(self): + for ok in ("root", "1000:1000", "infra-ops:infra-ops", "_apt"): + with self.subTest(owner=ok): + step = self.load(f""" + name: x + sudo: true + upload: {{src: a, dest: /etc/b, owner: {ok!r}}} + """) + self.assertEqual(step.upload.owner, ok) + + def test_owner_survives_var_substitution(self): + step = self.load(""" + name: x + sudo: true + upload: {src: a, dest: /etc/b, owner: "{{ svc }}:{{ svc }}"} + """) + new = elway.substitute_step(step, {"svc": "booth"}, "step 1") + self.assertEqual(new.upload.owner, "booth:booth") + + +class ModeAndTypeTests(unittest.TestCase): + def load(self, body): + return elway._load_step(__import__("yaml").safe_load(textwrap.dedent(body)), "step 1") + + def test_mode_with_shell_metacharacters_is_refused(self): + # mode is spliced into a root-run shell line; only octal digits pass. + for bad in ("0644 && id #", "0644;id", "u+x", "", "8644", "06444"): + with self.subTest(mode=bad), self.assertRaises(SystemExit): + self.load(f""" + name: x + sudo: true + upload: {{src: a, dest: /etc/b, mode: {bad!r}}} + """) + + def test_valid_modes_are_accepted(self): + for ok in ("644", "0644", "0700", "4755"): + with self.subTest(mode=ok): + self.assertEqual(self.load(f""" + name: x + upload: {{src: a, dest: /tmp/b, mode: {ok!r}}} + """).upload.mode, ok) + + def test_unquoted_yaml_mode_is_refused_not_reinterpreted(self): + # YAML 1.1 reads bare 0644 as octal int 420; str() would then chmod + # 420 (= r---w----), silently. Refuse and ask for a quoted string. + with self.assertRaises(SystemExit): + self.load(""" + name: x + upload: {src: a, dest: /tmp/b, mode: 0644} + """) + + def test_unquoted_yaml_owner_is_refused(self): + # bare 1000:0 is sexagesimal 60000; bare yes is True — both would + # stringify into something OWNER_RE accepts. + for bad in ("1000:0", "yes"): + with self.subTest(owner=bad), self.assertRaises(SystemExit): + self.load(f""" + name: x + sudo: true + upload: {{src: a, dest: /etc/b, owner: {bad}}} + """) + + def test_adhoc_bad_mode_is_refused(self): + args = elway.parse_args(["h", "--upload", "a:/etc/b:0644;id", "--sudo"]) + with self.assertRaises(SystemExit): + elway.build_adhoc_playbook(args) + + +class PreflightTests(unittest.TestCase): + def test_templated_bad_owner_is_refused_before_any_step_runs(self): + # Without a preflight, step 1 would already have run on the host when + # step 2's substituted owner is found to be malformed. + pb = elway.Playbook(vars={}, verify=[], steps=[ + elway.Step(name="stop", shell="systemctl stop x", sudo=True), + elway.Step(name="up", sudo=True, upload=elway.UploadSpec( + src="a", dest="/etc/b", owner="{{ o }}")), + ]) + with self.assertRaises(SystemExit): + elway.preflight(pb, {"o": "not a user"}) + + def test_undefined_variable_is_refused_before_any_step_runs(self): + pb = elway.Playbook(vars={}, verify=[], steps=[ + elway.Step(name="a", shell="true"), + elway.Step(name="b", shell="echo {{ missing }}"), + ]) + with self.assertRaises(SystemExit): + elway.preflight(pb, {}) + + def test_clean_playbook_passes_preflight(self): + pb = elway.Playbook(vars={}, verify=[], steps=[ + elway.Step(name="up", sudo=True, upload=elway.UploadSpec( + src="a", dest="/etc/b", owner="{{ o }}:{{ o }}"))]) + elway.preflight(pb, {"o": "booth"}) + + def test_main_runs_preflight_before_opening_ssh(self): + src = pathlib.Path(elway.__file__).read_text() + body = src[src.index("def main("):] + self.assertLess(body.index("preflight("), body.index("SSHContext(")) + + +class AdhocOwnerTests(unittest.TestCase): + def test_owner_flag_reaches_adhoc_upload(self): + args = elway.parse_args(["h", "--upload", "a:/etc/b", "--sudo", "--owner", "booth:booth"]) + (step,) = elway.build_adhoc_playbook(args).steps + self.assertEqual(step.upload.owner, "booth:booth") + + def test_owner_flag_outside_adhoc_upload_is_refused(self): + # Silently ignoring --owner on --shell or --playbook would let someone + # believe they had set an ownership that was never applied. + for argv in (["h", "--shell", "true", "--sudo", "--owner", "x"], + ["h", "--playbook", "p.yaml", "--owner", "x"]): + with self.subTest(argv=argv), self.assertRaises(SystemExit): + elway.parse_args(argv) + + def test_owner_flag_without_sudo_is_refused(self): + args = elway.parse_args(["h", "--upload", "a:/tmp/b", "--owner", "booth:booth"]) + with self.assertRaises(SystemExit): + elway.build_adhoc_playbook(args) + + +if __name__ == "__main__": + unittest.main()