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
+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.