fix: run privileged commands on the exec channel, never typed into the shell
CI / test (3.10) (push) Successful in 56s
CI / test (3.11) (push) Successful in 50s
CI / test (3.12) (push) Successful in 44s
CI / test (3.10) (pull_request) Successful in 42s
CI / test (3.11) (pull_request) Successful in 44s
CI / test (3.12) (pull_request) Successful in 53s
CI / test (3.10) (push) Successful in 56s
CI / test (3.11) (push) Successful in 50s
CI / test (3.12) (push) Successful in 44s
CI / test (3.10) (pull_request) Successful in 42s
CI / test (3.11) (pull_request) Successful in 44s
CI / test (3.12) (pull_request) Successful in 53s
_sudo typed `echo '<password>' | sudo -S -p "" <command>` into netmiko's interactive shell. bash kept it in its history, so every poll wrote the sudo password into the login user's ~/.bash_history, and while the command ran it was in the process list for every local user (NetOrk/netork#1039). _sudo, _sudo_status and _run_privileged with a password now run on an exec channel like run_command(privileged=True): the password reaches sudo -S on stdin, there is no PTY to echo it, and the script starts with `exec </dev/null` so a non-prompting sudo leaves it unread. The whole command runs as root in one sh -c - before, only the part up to the first `;`, `&&` or `|` did. The exit status comes from the channel. The interactive session switches its history off before its first command. get_vpn_tunnels with a password reads `wg show all dump` from the exec channel instead of a temp file that held the private keys.
This commit is contained in:
1 parent
7b37366a44
commit
cf8ee38a90
5 files changed
+440
-51
No files matched your search
+63
-27
@@ -69,8 +69,8 @@ logger = logging.getLogger("napalm_linux")
|
||||
# Package managers in detection order
|
||||
_PKG_MANAGERS = ["apt", "dnf", "yum", "apk", "pacman"]
|
||||
|
||||
#: Printed after a command by ``_sudo_status`` so its exit status survives the
|
||||
#: trip through an interactive shell. Matched only on a line of its own with a
|
||||
#: Printed after a command run through the interactive shell (``_run_privileged``
|
||||
#: callers) so its exit status survives the trip. Matched only on a line of its own with a
|
||||
#: number after it — an echoed command line carries the literal ``$?`` instead.
|
||||
_RC_MARKER = "__NETORK_RC="
|
||||
_RC_MARKER_RE = re.compile(rf"^{_RC_MARKER}(\d+)\s*$", re.MULTILINE)
|
||||
@@ -142,6 +142,16 @@ _PRIVILEGED_STDIN = (
|
||||
"mktemp, cat) and have root install that file."
|
||||
)
|
||||
|
||||
#: Sent before anything else in the interactive session (NetOrk/netork#1039):
|
||||
#: the shell writes no history file and records nothing more. The leading space
|
||||
#: keeps this line itself out of a history under ``HISTCONTROL=ignorespace``;
|
||||
#: a shell without ``set -o history`` (dash) only complains, to /dev/null.
|
||||
_HISTORY_OFF = " unset HISTFILE; set +o history 2>/dev/null; true"
|
||||
|
||||
#: First in every script ``_sudo`` runs: a sudo that does not prompt (NOPASSWD,
|
||||
#: a cached timestamp) leaves the password on stdin, and nothing may read it.
|
||||
_NO_STDIN = "exec </dev/null; "
|
||||
|
||||
#: What to do when sudo wants a password netOrk does not have.
|
||||
_SUDO_PASSWORD_HINT = (
|
||||
"sudo requires a password on this device but none is configured in netOrk. "
|
||||
@@ -307,6 +317,12 @@ class LinuxDriver(
|
||||
except NetmikoTimeoutException as exc:
|
||||
raise ConnectionException(str(exc)) from exc
|
||||
|
||||
# No history before anything else is typed (NetOrk/netork#1039).
|
||||
try:
|
||||
self._device.send_command(_HISTORY_OFF, expect_string=_PROMPT_RE)
|
||||
except Exception:
|
||||
logger.debug("Could not switch the shell history off on %s", self.hostname, exc_info=True)
|
||||
|
||||
# Prevent PTY from wrapping long output lines (e.g. docker JSON).
|
||||
try:
|
||||
self._device.send_command("stty cols 10000 2>/dev/null || true", expect_string=r"[#$>]\s*$")
|
||||
@@ -350,29 +366,38 @@ class LinuxDriver(
|
||||
).strip()
|
||||
|
||||
def _sudo(self, command: str, read_timeout: float = 100) -> str:
|
||||
"""Run *command* via sudo, feeding the password via stdin (-S).
|
||||
"""Run *command* as root and return its output, stdout and stderr together.
|
||||
|
||||
Falls back to plain execution when no sudo password is configured.
|
||||
It runs on an exec channel, not in netmiko's interactive shell
|
||||
(NetOrk/netork#1039): that shell keeps a history, and ``echo <password> |
|
||||
sudo -S`` typed into it wrote the password into ``~/.bash_history`` on
|
||||
every poll, and into the process list while it ran. On the exec channel
|
||||
the password reaches ``sudo -S`` on stdin (``_as_root``), and there is no
|
||||
PTY to echo it back into the output.
|
||||
|
||||
The whole of *command* runs as root in one ``sh -c``, ``;`` and ``&&``
|
||||
included. Without a sudo password ``sudo -n`` fails at once; a root
|
||||
login runs it directly.
|
||||
"""
|
||||
if self._sudo_password:
|
||||
wrapped = f'echo {_shlex_quote(self._sudo_password)} | sudo -S -p "" {command}'
|
||||
return self._send(wrapped, read_timeout=read_timeout)
|
||||
return self._send(f'sudo {command}', read_timeout=read_timeout)
|
||||
return self._sudo_result(command, read_timeout).stdout.strip()
|
||||
|
||||
def _sudo_result(self, command: str, timeout: float) -> CommandResult:
|
||||
line, prefix = self._as_root(f"{_NO_STDIN}{command}")
|
||||
return run_on_transport(self._transport(), f"{line} 2>&1", stdin=prefix, timeout=timeout)
|
||||
|
||||
def _sudo_status(self, command: str, read_timeout: float = 100) -> tuple[str, int | None]:
|
||||
"""Run *command* via sudo and return ``(output, exit_status)``.
|
||||
"""Run *command* as root and return ``(output, exit_status)``.
|
||||
|
||||
``_sudo`` callers append ``|| true`` so a failing command yields output
|
||||
instead of an error, which throws the exit status away. This variant
|
||||
echoes ``$?`` straight after the sudo pipeline instead — sudo passes
|
||||
the command's status through, and a failed password is non-zero too.
|
||||
instead of an error, which throws the exit status away. The exec channel
|
||||
reports the status itself: the command's, or sudo's when it refused.
|
||||
|
||||
The status is ``None`` when the marker never arrived (output cut short),
|
||||
so a caller can tell "unknown" from "succeeded".
|
||||
The status is ``None`` when the channel closed without one (paramiko's
|
||||
-1), so a caller can tell "unknown" from "succeeded".
|
||||
"""
|
||||
return _split_status(
|
||||
self._sudo(f"{command}; echo {_RC_MARKER}$?", read_timeout=read_timeout)
|
||||
)
|
||||
result = self._sudo_result(command, read_timeout)
|
||||
status = result.exit_code if result.exit_code >= 0 else None
|
||||
return result.stdout.strip(), status
|
||||
|
||||
def _is_root(self) -> bool:
|
||||
"""Whether the SSH user is root, asked once per session.
|
||||
@@ -401,7 +426,7 @@ class LinuxDriver(
|
||||
if self._is_root():
|
||||
return self._send(command, read_timeout=timeout)
|
||||
if self._sudo_password:
|
||||
return self._sudo(command, read_timeout=timeout)
|
||||
return self._sudo(command, read_timeout=timeout) # never typed into the shell
|
||||
return self._send(f"sudo -n {command}", read_timeout=timeout)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
@@ -425,10 +450,20 @@ class LinuxDriver(
|
||||
"""
|
||||
if not privileged or self._is_root():
|
||||
return command, None
|
||||
return self._as_root(command)
|
||||
|
||||
def _as_root(self, command: str) -> tuple[str, bytes | None]:
|
||||
"""The ``sh -c`` line that runs *command* as root, and its stdin prefix.
|
||||
|
||||
Root runs it directly; with a sudo password, ``sudo -S`` reads it from
|
||||
stdin; without one, ``sudo -n`` fails at once instead of prompting.
|
||||
"""
|
||||
script = _shlex_quote(command)
|
||||
if self._is_root():
|
||||
return f"sh -c {script}", None
|
||||
if self._sudo_password:
|
||||
line = f"sudo -S -p '' sh -c {_shlex_quote(command)}"
|
||||
return line, f"{self._sudo_password}\n".encode()
|
||||
return f"sudo -n sh -c {_shlex_quote(command)}", None
|
||||
return f"sudo -S -p '' sh -c {script}", f"{self._sudo_password}\n".encode()
|
||||
return f"sudo -n sh -c {script}", None
|
||||
|
||||
def run_command(
|
||||
self,
|
||||
@@ -2075,15 +2110,16 @@ class LinuxDriver(
|
||||
tunnels: dict[str, Any] = {}
|
||||
|
||||
# ── Attempt 1: wg show all dump via sudo ─────────────────────────────
|
||||
# Write output to a fixed temp file to preserve literal tab characters.
|
||||
# PTY output processing expands tabs to spaces, breaking split("\t").
|
||||
import hashlib as _hashlib
|
||||
_tmp = "/tmp/.netork_wg_" + _hashlib.md5(self.hostname.encode()).hexdigest()[:8]
|
||||
# With a sudo password it runs on the exec channel, which keeps the
|
||||
# tabs. The PTY expands them to spaces, breaking split("\t"), so
|
||||
# without one the output goes through a temp file.
|
||||
if self._sudo_password:
|
||||
self._sudo(f"wg show all dump > {_tmp} 2>&1 || true")
|
||||
raw = self._sudo("wg show all dump 2>&1 || true")
|
||||
else:
|
||||
import hashlib as _hashlib
|
||||
_tmp = "/tmp/.netork_wg_" + _hashlib.md5(self.hostname.encode()).hexdigest()[:8]
|
||||
self._send(f"sudo -n wg show all dump > {_tmp} 2>&1 || true")
|
||||
raw = self._send(f"cat {_tmp} 2>/dev/null || true; rm -f {_tmp}").strip()
|
||||
raw = self._send(f"cat {_tmp} 2>/dev/null || true; rm -f {_tmp}").strip()
|
||||
|
||||
_perm_errors = (
|
||||
"operation not permitted", "permission denied",
|
||||
|
||||
Reference in new issue
Block a user