fix(elway): sudo uploads land root:root, validated and staged; add fleet ownership audit

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.
This commit is contained in:
vh
2026-09-23 09:59:15 -07:00
parent 6e8da46a28
commit 466f7aa4e6
5 changed files with 555 additions and 14 deletions
@@ -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
@@ -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
+115 -10
View File
@@ -35,7 +35,12 @@ Playbook schema (YAML):
src: <local path>
dest: <remote path>
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.
+155
View File
@@ -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/<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
+279
View File
@@ -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()