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:
+115
-10
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user